@nettee/beacon 0.1.0

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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +163 -0
  3. package/deploy/io.nettee.beacon.plist.example +32 -0
  4. package/dist/cli-app.js +71 -0
  5. package/dist/cli.js +21 -0
  6. package/dist/config/global.js +84 -0
  7. package/dist/config/profile.js +126 -0
  8. package/dist/config/registry.js +12 -0
  9. package/dist/config/secrets.js +68 -0
  10. package/dist/config/yaml.js +33 -0
  11. package/dist/doctor.js +29 -0
  12. package/dist/domain/types.js +18 -0
  13. package/dist/feishu/doctor.js +21 -0
  14. package/dist/feishu/gateway.js +129 -0
  15. package/dist/feishu/intake.js +67 -0
  16. package/dist/feishu/message-gateway.js +1 -0
  17. package/dist/feishu/message-pipeline.js +14 -0
  18. package/dist/feishu/reply-experiment.js +111 -0
  19. package/dist/feishu/trigger-input.js +46 -0
  20. package/dist/manual-trigger.js +59 -0
  21. package/dist/message/gateway.js +1 -0
  22. package/dist/outcome/doctor.js +29 -0
  23. package/dist/outcome/server.js +108 -0
  24. package/dist/outcome/submit.js +47 -0
  25. package/dist/run/create-pi-orchestrator.js +21 -0
  26. package/dist/run/orchestrator.js +269 -0
  27. package/dist/run/profile-runner.js +39 -0
  28. package/dist/run/queue.js +46 -0
  29. package/dist/runtime/pi-doctor.js +14 -0
  30. package/dist/runtime/pi-outcome-extension.js +53 -0
  31. package/dist/runtime/pi-rpc.js +247 -0
  32. package/dist/schedule/cron.js +48 -0
  33. package/dist/schedule/cursor-store.js +105 -0
  34. package/dist/schedule/loop.js +45 -0
  35. package/dist/schedule/reconciler.js +41 -0
  36. package/dist/service.js +97 -0
  37. package/dist/state/trigger-store.js +234 -0
  38. package/dist/version.js +9 -0
  39. package/examples/config.yaml +12 -0
  40. package/examples/profiles/example/profile.yaml +13 -0
  41. package/examples/profiles/example/prompt.md +1 -0
  42. package/examples/secrets.json.example +11 -0
  43. package/package.json +61 -0
@@ -0,0 +1,234 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, open, readdir, readFile, rename, } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { failureCodes, } from "../domain/types.js";
6
+ const timestamp = z.string().datetime({ offset: true });
7
+ const failureSchema = z
8
+ .object({ code: z.enum(failureCodes), summary: z.string().min(1).max(4096) })
9
+ .strict();
10
+ const targetSchema = z.discriminatedUnion("kind", [
11
+ z.object({ kind: z.literal("reply"), messageId: z.string().min(1) }).strict(),
12
+ z.object({ kind: z.literal("chat"), chatId: z.string().min(1) }).strict(),
13
+ z.object({ kind: z.literal("local_stdout") }).strict(),
14
+ ]);
15
+ const messageSchema = z
16
+ .object({
17
+ messageId: z.string().min(1),
18
+ messageType: z.string().min(1),
19
+ senderType: z.string().min(1),
20
+ senderId: z.string().min(1).optional(),
21
+ content: z.unknown(),
22
+ })
23
+ .strict();
24
+ const inputSchema = z.discriminatedUnion("kind", [
25
+ z
26
+ .object({
27
+ kind: z.literal("feishu_message"),
28
+ eventId: z.string().min(1),
29
+ chatType: z.enum(["p2p", "group"]),
30
+ quotedMessages: z.array(messageSchema),
31
+ currentMessage: messageSchema,
32
+ })
33
+ .strict(),
34
+ z
35
+ .object({
36
+ kind: z.literal("schedule"),
37
+ scheduleId: z.string().min(1),
38
+ scheduledFor: timestamp,
39
+ text: z.string().min(1),
40
+ })
41
+ .strict(),
42
+ z.object({ kind: z.literal("manual"), text: z.string().min(1) }).strict(),
43
+ ]);
44
+ const runSchema = z
45
+ .object({
46
+ runId: z.string().min(1),
47
+ state: z.enum(["queued", "starting", "running", "succeeded", "failed"]),
48
+ queuedAt: timestamp,
49
+ startedAt: timestamp.optional(),
50
+ finishedAt: timestamp.optional(),
51
+ provider: z.string().min(1),
52
+ model: z.string().min(1),
53
+ workspace: z.string().min(1),
54
+ promptDigest: z.string().regex(/^[a-f0-9]{64}$/),
55
+ failure: failureSchema.optional(),
56
+ })
57
+ .strict();
58
+ const outcomeSchema = z
59
+ .object({
60
+ origin: z.enum(["agent", "beacon_failure"]),
61
+ text: z.string().min(1),
62
+ submittedAt: timestamp,
63
+ })
64
+ .strict();
65
+ const deliverySchema = z
66
+ .object({
67
+ deliveryId: z.string().min(1),
68
+ target: targetSchema,
69
+ state: z.enum(["pending", "delivering", "delivered", "failed"]),
70
+ startedAt: timestamp.optional(),
71
+ finishedAt: timestamp.optional(),
72
+ providerRequestId: z.string().min(1).optional(),
73
+ failure: failureSchema.optional(),
74
+ })
75
+ .strict();
76
+ const triggerRecordSchema = z
77
+ .object({
78
+ version: z.literal(1),
79
+ triggerKey: z.string().regex(/^[a-f0-9]{64}$/),
80
+ triggerId: z.string().min(1),
81
+ profileId: z.string().min(1),
82
+ sourceKey: z.array(z.string().min(1)).min(2),
83
+ acceptedAt: timestamp,
84
+ target: targetSchema,
85
+ input: inputSchema.optional(),
86
+ ingress: z.record(z.string(), z.unknown()).optional(),
87
+ run: runSchema.optional(),
88
+ finalOutcome: outcomeSchema.optional(),
89
+ delivery: deliverySchema.optional(),
90
+ })
91
+ .strict();
92
+ function keyFor(profileId, sourceKey) {
93
+ return createHash("sha256")
94
+ .update(JSON.stringify(["beacon-trigger-v1", profileId, ...sourceKey]))
95
+ .digest("hex");
96
+ }
97
+ function validateRecord(value) {
98
+ const record = triggerRecordSchema.parse(value);
99
+ if (record.delivery && !record.finalOutcome) {
100
+ throw new Error("Trigger record with Delivery must contain a Final Outcome");
101
+ }
102
+ if (record.run?.state === "succeeded" &&
103
+ record.finalOutcome?.origin !== "agent") {
104
+ throw new Error("Succeeded Run must contain an Agent Final Outcome");
105
+ }
106
+ if (record.run?.state === "failed" && !record.run.failure) {
107
+ throw new Error("Failed Run must contain failure details");
108
+ }
109
+ return record;
110
+ }
111
+ export class TriggerStore {
112
+ profileId;
113
+ root;
114
+ constructor(profileDirectory, profileId) {
115
+ this.profileId = profileId;
116
+ this.root = join(profileDirectory, "state", "triggers");
117
+ }
118
+ recordPath(triggerKey) {
119
+ return join(this.root, triggerKey, "record.json");
120
+ }
121
+ async prepare() {
122
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
123
+ await chmod(join(this.root, ".."), 0o700);
124
+ await chmod(this.root, 0o700);
125
+ }
126
+ async read(triggerKey) {
127
+ const path = this.recordPath(triggerKey);
128
+ let raw;
129
+ try {
130
+ raw = await readFile(path, "utf8");
131
+ }
132
+ catch (error) {
133
+ throw new Error(`Cannot read Trigger record at ${path}`, {
134
+ cause: error,
135
+ });
136
+ }
137
+ try {
138
+ return validateRecord(JSON.parse(raw));
139
+ }
140
+ catch (error) {
141
+ throw new Error(`Cannot parse Trigger record at ${path}`, {
142
+ cause: error,
143
+ });
144
+ }
145
+ }
146
+ async readConcurrentClaim(triggerKey) {
147
+ for (let attempt = 0; attempt < 100; attempt += 1) {
148
+ try {
149
+ return await this.read(triggerKey);
150
+ }
151
+ catch (error) {
152
+ const cause = error instanceof Error
153
+ ? error.cause
154
+ : undefined;
155
+ if (cause?.code !== "ENOENT")
156
+ throw error;
157
+ await new Promise((resolve) => setTimeout(resolve, 5));
158
+ }
159
+ }
160
+ return this.read(triggerKey);
161
+ }
162
+ async write(triggerKey, record) {
163
+ const validated = validateRecord(record);
164
+ const directory = join(this.root, triggerKey);
165
+ const temporary = join(directory, `.record-${randomUUID()}.tmp`);
166
+ const handle = await open(temporary, "wx", 0o600);
167
+ try {
168
+ await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, "utf8");
169
+ await handle.sync();
170
+ }
171
+ finally {
172
+ await handle.close();
173
+ }
174
+ await rename(temporary, this.recordPath(triggerKey));
175
+ const directoryHandle = await open(directory, "r");
176
+ try {
177
+ await directoryHandle.sync();
178
+ }
179
+ finally {
180
+ await directoryHandle.close();
181
+ }
182
+ }
183
+ async claim(request) {
184
+ if (request.sourceKey.length < 2 ||
185
+ request.sourceKey.some((part) => !part)) {
186
+ throw new Error("Trigger source key must contain at least two non-empty parts");
187
+ }
188
+ await this.prepare();
189
+ const triggerKey = keyFor(this.profileId, request.sourceKey);
190
+ const directory = join(this.root, triggerKey);
191
+ try {
192
+ await mkdir(directory, { mode: 0o700 });
193
+ }
194
+ catch (error) {
195
+ if (error.code === "EEXIST") {
196
+ return {
197
+ created: false,
198
+ record: await this.readConcurrentClaim(triggerKey),
199
+ };
200
+ }
201
+ throw new Error(`Cannot claim Trigger ${triggerKey}`, { cause: error });
202
+ }
203
+ const record = {
204
+ version: 1,
205
+ triggerKey,
206
+ triggerId: `trg_${triggerKey.slice(0, 24)}`,
207
+ profileId: this.profileId,
208
+ sourceKey: [...request.sourceKey],
209
+ acceptedAt: (request.acceptedAt ?? new Date()).toISOString(),
210
+ target: request.target,
211
+ ...(request.ingress ? { ingress: request.ingress } : {}),
212
+ };
213
+ await this.write(triggerKey, record);
214
+ return { created: true, record };
215
+ }
216
+ async update(triggerKey, transform) {
217
+ const current = await this.read(triggerKey);
218
+ const next = transform(structuredClone(current));
219
+ if (next.triggerKey !== triggerKey || next.profileId !== this.profileId) {
220
+ throw new Error("Trigger identity cannot change during update");
221
+ }
222
+ await this.write(triggerKey, next);
223
+ return next;
224
+ }
225
+ async list() {
226
+ await this.prepare();
227
+ const entries = await readdir(this.root, { withFileTypes: true });
228
+ const records = await Promise.all(entries
229
+ .filter((entry) => entry.isDirectory())
230
+ .map((entry) => this.read(entry.name)));
231
+ return records.sort((left, right) => left.acceptedAt.localeCompare(right.acceptedAt) ||
232
+ left.triggerId.localeCompare(right.triggerId));
233
+ }
234
+ }
@@ -0,0 +1,9 @@
1
+ import { createRequire } from "node:module";
2
+ const metadata = createRequire(import.meta.url)("../package.json");
3
+ const version = typeof metadata === "object" && metadata !== null
4
+ ? metadata.version
5
+ : undefined;
6
+ if (typeof version !== "string" || !version) {
7
+ throw new Error("Beacon package metadata must contain a version");
8
+ }
9
+ export const packageVersion = version;
@@ -0,0 +1,12 @@
1
+ version: 1
2
+ profiles_directory: profiles
3
+ pi:
4
+ executable: /ABSOLUTE/PATH/TO/pi
5
+ coding_agent_directory: /Users/USERNAME/.pi/agent
6
+ runs:
7
+ max_concurrent: 2
8
+ max_queued: 100
9
+ timeout_seconds: 1800
10
+ terminate_grace_seconds: 5
11
+ scheduler:
12
+ max_occurrences_per_reconciliation: 1000
@@ -0,0 +1,13 @@
1
+ prompt: prompt.md
2
+ workspace: /ABSOLUTE/PATH/TO/WORKSPACE
3
+ runtime: pi
4
+ model:
5
+ provider: REPLACE_WITH_PI_PROVIDER
6
+ id: REPLACE_WITH_PI_MODEL
7
+ schedules:
8
+ - id: weekday-brief
9
+ cron: "0 9 * * 1-5"
10
+ timezone: Asia/Shanghai
11
+ input: Prepare the weekday brief.
12
+ delivery:
13
+ chat_id: REPLACE_WITH_DIRECT_OR_GROUP_CHAT_ID
@@ -0,0 +1 @@
1
+ You are the configured Beacon Profile. Work only within the provided workspace and return a concise, user-facing result through the required Final Outcome tool.
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "profiles": {
4
+ "example": {
5
+ "feishu": {
6
+ "app_id": "cli_0123456789abcdef",
7
+ "app_secret": "REPLACE_ME"
8
+ }
9
+ }
10
+ }
11
+ }
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@nettee/beacon",
3
+ "version": "0.1.0",
4
+ "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/nettee/beacon.git"
9
+ },
10
+ "homepage": "https://github.com/nettee/beacon#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/nettee/beacon/issues"
13
+ },
14
+ "keywords": [
15
+ "agent",
16
+ "cli",
17
+ "feishu",
18
+ "automation"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "beacon": "dist/cli.js"
23
+ },
24
+ "files": [
25
+ "dist/",
26
+ "deploy/io.nettee.beacon.plist.example",
27
+ "examples/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.json",
36
+ "check": "biome check .",
37
+ "dev": "tsx src/cli.ts",
38
+ "e2e:message": "tsc -p tsconfig.e2e.json --noEmit && pnpm build && tsx --test e2e/message/*.e2e.test.ts",
39
+ "prebuild": "node scripts/clean-dist.mjs",
40
+ "prepack": "pnpm build",
41
+ "test": "tsx --test src/*.test.ts src/**/*.test.ts scripts/*.test.mjs",
42
+ "test:package": "node scripts/test-package.mjs",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit"
44
+ },
45
+ "packageManager": "pnpm@10.33.2",
46
+ "engines": {
47
+ "node": ">=22"
48
+ },
49
+ "dependencies": {
50
+ "@larksuiteoapi/node-sdk": "^1.58.0",
51
+ "cron-parser": "^5.10.0",
52
+ "yaml": "^2.9.1",
53
+ "zod": "^4.1.8"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.5.13",
57
+ "@types/node": "^24.3.0",
58
+ "tsx": "^4.20.5",
59
+ "typescript": "^5.9.2"
60
+ }
61
+ }