@nettee/beacon 0.3.0 → 0.3.1

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
@@ -371,16 +371,27 @@ as Markdown on stdout.
371
371
 
372
372
  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
373
 
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.
374
+ Every new business Run also owns a permanent Pi session by default. Its Run
375
+ record stores both `sessionId` and `sessionPath`. For a fresh Trigger the
376
+ session ID is exactly the Beacon `runId`, and the path is the dedicated
377
+ directory `<pi.session_directory>/<profile-id>/<runId>/`. Pi receives that
378
+ mapping via `--session-dir`, `--session-id`, and a readable
379
+ `Beacon <profile-id> <runId>` name. The directory contains the Pi JSONL
380
+ session file and is never cleaned up by Beacon. A queued Run keeps the same
381
+ mapping after restart. Queued records written by Beacon 0.1.2 or earlier are
382
+ assigned the same deterministic mapping when recovered. Historical completed
383
+ records remain readable and may omit the two session fields because those Runs
384
+ were originally ephemeral.
385
+
386
+ When an inbound Feishu message is a quote-reply (`parent_id`) to a message
387
+ Beacon previously delivered (`reply_text` / `reply_card` / `notify_card`, or a
388
+ failure reply), Beacon looks up that outbound Feishu `message_id` on the
389
+ matching Delivery / notify Delivery (`providerRequestId`), then starts a new
390
+ Run that **reuses the prior Pi session** and appends one new user prompt.
391
+ Follow-up Runs keep their own `runId` but set `sessionId` / `sessionPath` to
392
+ the original session. If the quoted message is not a Beacon Delivery, or the
393
+ Delivery did not persist a Feishu `message_id`, Beacon falls back to a fresh
394
+ session.
384
395
 
385
396
  To locate a session from a reported `run_id`, first find its durable Run
386
397
  record, then inspect or export the sole JSONL file in `sessionPath`:
@@ -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
  }
@@ -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));
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nettee/beacon",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "A single-host bridge from messaging and schedules to isolated agent runs",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {