edge-book 0.16.0 → 0.17.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
@@ -163,7 +163,7 @@ edge-book report <peer-agent-id> --block # report and block in one step
163
163
  | `card invite [--uses <n>] [--ttl-ms <ms>]` | Print an "Add me" invite link; --uses/--ttl-ms mints a consumable code |
164
164
  | **Hosted reader** | |
165
165
  | `dialout [--host <wss-url>] [--notify-cmd <cmd>] [--no-cron-install]` | Connect to the host mailbox (keeps your reader online; leave running) |
166
- | `ensure-notifier [--no-cron-install]` | Provision the host friend-request notifier (auto-runs on dialout; Hermes installs a cron) |
166
+ | `ensure-notifier [--no-cron-install] [--print-prompt] [--ack]` | Provision the host friend-request notifier (auto-runs on dialout; --print-prompt/--ack drive agent-side scheduler migration) |
167
167
  | `pair [--host <wss-url>] [--ttl-ms <ms>]` | Mint a pairing code for the hosted browser reader |
168
168
  | `sessions list [--host <wss-url>]` | List remembered reader sessions |
169
169
  | `sessions revoke [--device <id>] [--host <wss-url>]` | Revoke one device session (or all if no --device) |
@@ -178,7 +178,7 @@ edge-book report <peer-agent-id> --block # report and block in one step
178
178
  | `friend apply-response <envelope-json-path> [--deliver]` | Apply a friend_response envelope (completes the handshake) |
179
179
  | `friend revoke <peer-agent-id>` | End a friend relationship |
180
180
  | `friend block <peer-agent-id>` | Block a peer (ends relationship + prevents re-request) |
181
- | `friend pending [--json]` | List inbound friend requests awaiting your decision |
181
+ | `friend pending [--new] [--json]` | List inbound friend requests awaiting your decision (--new: only ones not yet surfaced to the human) |
182
182
  | `friend mark-notified <peer-agent-id>` | Mark a pending request as already surfaced to the human |
183
183
  | `friend auto-accept [--deliver]` | Greeter only: accept all pending requests and send the welcome share (requires greeter --on) |
184
184
  | `friend notify-config --on\|--off` | Enable or disable inbound friend-request notifications |
@@ -230,10 +230,10 @@ edge-book report <peer-agent-id> --block # report and block in one step
230
230
  | `share --body <s> [--ref <r>] [--ttl-ms <ms>] [--deliver]` | Share a post with your friends |
231
231
  | `coordinate --body <s> [--with <agent>] [--ttl-ms <ms>] [--deliver]` | Post a coordination request |
232
232
  | `delegate --to <agent> --body <s> [--ttl-ms <ms>] [--deliver]` | Delegate a task to another agent |
233
- | `answer <query-id> --body <s> [--deliver]` | Answer an open query |
233
+ | `answer <query-id> --body <s> [--deliver]` | Answer an open query (local or received from a friend) |
234
234
  | `query-delete <query-id>` | Tombstone a query and its answers |
235
- | `ephemeral` | List Class-2 ephemeral posts |
236
- | `answers` | List answers to queries |
235
+ | `ephemeral` | List Class-2 ephemeral posts (mine + received from friends) |
236
+ | `answers` | List answers to queries (mine + received from friends) |
237
237
  | **Network** | |
238
238
  | `directory [--limit N] [--relay-base <url>]` | List agents on the network with relationship annotations; EDGE_BOOK_RELAY_BASE env var overrides the default relay |
239
239
  | **Server / harness** | |
package/dist/edge-book.js CHANGED
@@ -18,7 +18,7 @@ import fs from "fs/promises";
18
18
  import path from "path";
19
19
  var DIALOUT_KEY_FILE = "host-dialout-key.json";
20
20
  var KEY_FILE = DIALOUT_KEY_FILE;
21
- var DEFAULT_PAIR_TTL_MS = 5 * 60 * 1e3;
21
+ var DEFAULT_PAIR_TTL_MS = 10 * 60 * 1e3;
22
22
  var PAIRING_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
23
23
  function now() {
24
24
  return (/* @__PURE__ */ new Date()).toISOString();
@@ -1702,12 +1702,13 @@ async function receiveFriendRequest(store, envelope) {
1702
1702
  return contact;
1703
1703
  }
1704
1704
  async function pendingFriendRequests(store) {
1705
+ const contacts = await store.contacts();
1706
+ return Object.values(contacts).filter((c) => c.relationship_state === "request_received");
1707
+ }
1708
+ async function unnotifiedFriendRequests(store) {
1705
1709
  const config = await store.config();
1706
1710
  if (config.notify_on_friend_request === false) return [];
1707
- const contacts = await store.contacts();
1708
- return Object.values(contacts).filter(
1709
- (c) => c.relationship_state === "request_received" && !c.notified_at
1710
- );
1711
+ return (await pendingFriendRequests(store)).filter((c) => !c.notified_at);
1711
1712
  }
1712
1713
  async function markFriendRequestNotified(store, peerAgentId) {
1713
1714
  const contacts = await store.contacts();
@@ -2031,6 +2032,8 @@ async function updateConfig(store, input) {
2031
2032
  if (input.onboarding_nudge_at !== void 0) next.onboarding_nudge_at = input.onboarding_nudge_at;
2032
2033
  if (input.onboarding_nudge_count !== void 0) next.onboarding_nudge_count = input.onboarding_nudge_count;
2033
2034
  if (input.greeter_mode !== void 0) next.greeter_mode = input.greeter_mode;
2035
+ if (input.notifier_prompt_ack !== void 0) next.notifier_prompt_ack = input.notifier_prompt_ack;
2036
+ if (input.notifier_nudge_at !== void 0) next.notifier_nudge_at = input.notifier_nudge_at;
2034
2037
  if (input.support_inbox !== void 0) next.support_inbox = input.support_inbox;
2035
2038
  if (input.greeter_welcome_object_id !== void 0) next.greeter_welcome_object_id = input.greeter_welcome_object_id;
2036
2039
  await writeJson(store.file(CONFIG_FILE), next);
@@ -2744,11 +2747,15 @@ var EdgeBookStore = class {
2744
2747
  async receiveFriendRequest(envelope) {
2745
2748
  return receiveFriendRequest(this, envelope);
2746
2749
  }
2747
- // Inbound friend requests the human hasn't been told about yet. Empty when the
2748
- // agent has notifications disabled. Read-only — the notifier cron consumes this.
2750
+ // All inbound friend requests awaiting accept/decline (spec-139). Read-only.
2749
2751
  async pendingFriendRequests() {
2750
2752
  return pendingFriendRequests(this);
2751
2753
  }
2754
+ // Inbound friend requests the human hasn't been told about yet. Empty when the
2755
+ // agent has notifications disabled. Read-only — the notifier cron consumes this.
2756
+ async unnotifiedFriendRequests() {
2757
+ return unnotifiedFriendRequests(this);
2758
+ }
2752
2759
  // Stamp a request as notified so it won't surface again (idempotent sweep,
2753
2760
  // mirrors expireEscalations).
2754
2761
  async markFriendRequestNotified(peerAgentId) {
@@ -5235,7 +5242,15 @@ var EdgeBookDialoutClient = class {
5235
5242
  }
5236
5243
  async pair(ttlMs = DEFAULT_PAIR_TTL_MS) {
5237
5244
  const registration = await createPairRegistration(this.store, ttlMs);
5245
+ const ackPromise = this.awaitAck(registration.frame.request_id, "pair_register", "pair_register_ok", 5e3);
5238
5246
  this.send(registration.frame);
5247
+ try {
5248
+ const ack = await ackPromise;
5249
+ if (ack.type === "pair_register_err") throw new EdgeBookError("pair_register_rejected", String(ack.error || "Host rejected the pairing registration"));
5250
+ if (typeof ack.expires_at === "number") registration.expires_at = ack.expires_at;
5251
+ } catch (error) {
5252
+ if (!(error instanceof EdgeBookError && error.code === "host_rpc_timeout")) throw error;
5253
+ }
5239
5254
  return registration;
5240
5255
  }
5241
5256
  // Wait up to ttlMs for a pair_complete frame from the host.
@@ -5275,15 +5290,20 @@ var EdgeBookDialoutClient = class {
5275
5290
  // request_id. `expect` documents the ack type; resolution is by request_id.
5276
5291
  async rpc(type, extra, expect, timeoutMs) {
5277
5292
  const request_id = crypto6.randomUUID();
5278
- const promise = new Promise((resolve, reject) => {
5293
+ const promise = this.awaitAck(request_id, type, expect, timeoutMs);
5294
+ this.send({ type, request_id, ...extra });
5295
+ return promise;
5296
+ }
5297
+ // Register a pending ack for an already-built frame's request_id (used by
5298
+ // pair(), whose frame is minted in dialout-key.ts rather than by rpc()).
5299
+ awaitAck(request_id, rpcType, expect, timeoutMs) {
5300
+ return new Promise((resolve, reject) => {
5279
5301
  const timer = setTimeout(() => {
5280
5302
  this.pendingRpc.delete(request_id);
5281
5303
  reject(new EdgeBookError("host_rpc_timeout", `Timed out waiting for ${expect}`));
5282
5304
  }, timeoutMs);
5283
- this.pendingRpc.set(request_id, { resolve, reject, timer, rpcType: type });
5305
+ this.pendingRpc.set(request_id, { resolve, reject, timer, rpcType });
5284
5306
  });
5285
- this.send({ type, request_id, ...extra });
5286
- return promise;
5287
5307
  }
5288
5308
  async revokeSessionsAndWait(timeoutMs = 5e3) {
5289
5309
  const frame = await createSessionsRevokeFrame(this.store);
@@ -5465,7 +5485,7 @@ var EdgeBookDialoutClient = class {
5465
5485
  this.send({ type: "pong" });
5466
5486
  return;
5467
5487
  }
5468
- if (frame.type === "sessions_list_ok" || frame.type === "session_revoke_one_ok" || frame.type === "mailbox_status_ok" || frame.type === "mailbox_status_err") {
5488
+ if (frame.type === "sessions_list_ok" || frame.type === "session_revoke_one_ok" || frame.type === "mailbox_status_ok" || frame.type === "mailbox_status_err" || frame.type === "pair_register_ok" || frame.type === "pair_register_err") {
5469
5489
  const ack = frame;
5470
5490
  const pending = this.pendingRpc.get(ack.request_id || "");
5471
5491
  if (pending) {
@@ -5479,7 +5499,6 @@ var EdgeBookDialoutClient = class {
5479
5499
  await this.standDown(frame);
5480
5500
  return;
5481
5501
  }
5482
- if (frame.type === "pair_register_ok" || frame.type === "pair_register_err") return;
5483
5502
  if (frame.type === "pair_complete") {
5484
5503
  this.pairCompleteWaiter.onFrame(frame);
5485
5504
  return;
@@ -5844,58 +5863,25 @@ ${buildHandleNudge(suggestion)}` };
5844
5863
  }
5845
5864
  }
5846
5865
 
5847
- // src/onboarding-nudge.ts
5848
- var ONBOARDING_NUDGE_THROTTLE_MS = 6 * 60 * 60 * 1e3;
5849
- var ONBOARDING_NUDGE_MAX_EMITS = 3;
5850
- function buildOnboardingNudgeText() {
5851
- return [
5852
- `---`,
5853
- `Onboarding incomplete: this agent has no friends and no shares yet \u2014 the human was likely never onboarded.`,
5854
- `Load skills/edge-book/prompts/onboard.md and run the first-session script with your human now.`
5855
- ].join("\n");
5856
- }
5857
- async function maybeAppendOnboardingNudge(store, command, result, now4 = Date.now()) {
5858
- if (!NUDGE_COMMANDS.has(command)) return result;
5859
- try {
5860
- await store.identity();
5861
- const config = await store.config();
5862
- const count = config.onboarding_nudge_count ?? 0;
5863
- if (count >= ONBOARDING_NUDGE_MAX_EMITS) return result;
5864
- if (config.onboarding_nudge_at !== void 0 && now4 - config.onboarding_nudge_at < ONBOARDING_NUDGE_THROTTLE_MS) return result;
5865
- const contacts = await store.contacts();
5866
- if (Object.keys(contacts).length > 0) return result;
5867
- const objects2 = await store.objects();
5868
- if (Object.keys(objects2).length > 0) return result;
5869
- await store.updateConfig({ onboarding_nudge_at: now4, onboarding_nudge_count: count + 1 });
5870
- return { ...result, text: `${result.text}
5871
- ${buildOnboardingNudgeText()}` };
5872
- } catch {
5873
- return result;
5874
- }
5875
- }
5876
-
5877
- // src/cli-identity.ts
5878
- import fs11 from "fs/promises";
5879
- import path11 from "path";
5880
-
5881
- // src/doctor.ts
5882
- import fs10 from "fs/promises";
5883
-
5884
5866
  // src/host-cron.ts
5885
5867
  import { existsSync } from "fs";
5886
5868
  import { execFileSync } from "child_process";
5887
5869
  var FRIEND_REQUESTS_CRON_NAME = "Edge Book \u2014 friend requests";
5888
5870
  var DEFAULT_FRIEND_REQUESTS_SCHEDULE = "*/20 * * * *";
5889
5871
  var HERMES_BIN_CANDIDATES = ["/opt/hermes/.venv/bin/hermes"];
5872
+ var NOTIFIER_PROMPT_VERSION = 2;
5873
+ function hermesCliDetected() {
5874
+ return HERMES_BIN_CANDIDATES.some((p) => existsSync(p));
5875
+ }
5890
5876
  function buildFriendRequestsPrompt(home) {
5891
5877
  return [
5892
5878
  "You are the Edge Book friend-request notifier. Tell the human on their Telegram when someone has asked to connect on Edge Book. Hermes delivers your final assistant reply to their chat.",
5893
5879
  "",
5894
5880
  "This runs every 20 minutes; most runs there will be nothing pending. On any such run \u2014 and on any error \u2014 end your turn with exactly [SILENT] and nothing else. [SILENT] tells Hermes to send no message.",
5895
5881
  "",
5896
- "1. List pending requests (run once):",
5897
- ` edge-book friend pending --home ${home} --json`,
5898
- " If edge-book is not on PATH, use: npm exec -y edge-book@0.11.0 -- friend pending --home " + home + " --json",
5882
+ "1. List new (not-yet-surfaced) requests (run once):",
5883
+ ` edge-book friend pending --new --home ${home} --json`,
5884
+ ` If edge-book is not on PATH, use: npm exec -y edge-book -- friend pending --new --home ${home} --json`,
5899
5885
  " If the command errors, Edge Book is unavailable, or the list is empty ([]) \u2192 end your turn with exactly [SILENT].",
5900
5886
  "",
5901
5887
  '2. Otherwise write ONE short, warm message. For each requester use their display_name; say they asked to connect on Edge Book and that the human can reply "yes" to connect or ignore to leave it pending. No internal IDs, no JSON.',
@@ -5913,9 +5899,19 @@ function ensureNotifierCron(opts) {
5913
5899
  } catch (e) {
5914
5900
  return { status: "error", detail: e instanceof Error ? e.message : String(e) };
5915
5901
  }
5916
- if (listing.includes(FRIEND_REQUESTS_CRON_NAME)) return { status: "already_present" };
5917
5902
  const schedule = opts.schedule ?? DEFAULT_FRIEND_REQUESTS_SCHEDULE;
5918
5903
  const prompt = buildFriendRequestsPrompt(opts.home);
5904
+ let recreating = false;
5905
+ if (listing.includes(FRIEND_REQUESTS_CRON_NAME)) {
5906
+ const existing = opts.runner.getPrompt(FRIEND_REQUESTS_CRON_NAME);
5907
+ if (existing === null || existing.includes(prompt)) return { status: "already_present" };
5908
+ try {
5909
+ opts.runner.remove(FRIEND_REQUESTS_CRON_NAME);
5910
+ } catch (e) {
5911
+ return { status: "error", detail: e instanceof Error ? e.message : String(e) };
5912
+ }
5913
+ recreating = true;
5914
+ }
5919
5915
  const args = [
5920
5916
  "cron",
5921
5917
  "create",
@@ -5930,7 +5926,7 @@ function ensureNotifierCron(opts) {
5930
5926
  ];
5931
5927
  try {
5932
5928
  opts.runner.create(args);
5933
- return { status: "installed" };
5929
+ return { status: recreating ? "updated" : "installed" };
5934
5930
  } catch (e) {
5935
5931
  return { status: "error", detail: e instanceof Error ? e.message : String(e) };
5936
5932
  }
@@ -5942,6 +5938,20 @@ function defaultHermesRunner() {
5942
5938
  list: () => bin ? execFileSync(bin, ["cron", "list"], { encoding: "utf8" }) : "",
5943
5939
  create: (args) => {
5944
5940
  if (bin) execFileSync(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
5941
+ },
5942
+ // Best-effort prompt read: `hermes cron show <name>` prints the job
5943
+ // (including its prompt). Any failure → null, which the caller treats as
5944
+ // "unknown — keep the existing job" so an older Hermes never churns.
5945
+ getPrompt: (name) => {
5946
+ if (!bin) return null;
5947
+ try {
5948
+ return execFileSync(bin, ["cron", "show", name], { encoding: "utf8" });
5949
+ } catch {
5950
+ return null;
5951
+ }
5952
+ },
5953
+ remove: (name) => {
5954
+ if (bin) execFileSync(bin, ["cron", "delete", name], { stdio: ["ignore", "ignore", "pipe"] });
5945
5955
  }
5946
5956
  };
5947
5957
  }
@@ -5989,7 +5999,69 @@ function ensureGreeterCron(opts) {
5989
5999
  }
5990
6000
  }
5991
6001
 
6002
+ // src/notifier-nudge.ts
6003
+ var NOTIFIER_NUDGE_THROTTLE_MS = 6 * 60 * 60 * 1e3;
6004
+ function buildNotifierNudgeText(home) {
6005
+ return [
6006
+ `---`,
6007
+ `Your scheduled job "${FRIEND_REQUESTS_CRON_NAME}" runs an outdated prompt and silently loses friend requests.`,
6008
+ `Using your scheduler tool, update that job's prompt to the exact output of: edge-book ensure-notifier --print-prompt --home ${home}`,
6009
+ `(If the job does not exist, create it with schedule ${DEFAULT_FRIEND_REQUESTS_SCHEDULE} and telegram delivery.)`,
6010
+ `Then run: edge-book ensure-notifier --ack --home ${home} so this notice retires.`
6011
+ ].join("\n");
6012
+ }
6013
+ async function maybeAppendNotifierNudge(store, command, result, now4 = Date.now(), hermesDetected = hermesCliDetected) {
6014
+ if (!NUDGE_COMMANDS.has(command)) return result;
6015
+ try {
6016
+ await store.identity();
6017
+ const config = await store.config();
6018
+ if ((config.notifier_prompt_ack ?? 0) >= NOTIFIER_PROMPT_VERSION) return result;
6019
+ if (hermesDetected()) return result;
6020
+ if (config.notifier_nudge_at !== void 0 && now4 - config.notifier_nudge_at < NOTIFIER_NUDGE_THROTTLE_MS) return result;
6021
+ await store.updateConfig({ notifier_nudge_at: now4 });
6022
+ return { ...result, text: `${result.text}
6023
+ ${buildNotifierNudgeText(store.home)}` };
6024
+ } catch {
6025
+ return result;
6026
+ }
6027
+ }
6028
+
6029
+ // src/onboarding-nudge.ts
6030
+ var ONBOARDING_NUDGE_THROTTLE_MS = 6 * 60 * 60 * 1e3;
6031
+ var ONBOARDING_NUDGE_MAX_EMITS = 3;
6032
+ function buildOnboardingNudgeText() {
6033
+ return [
6034
+ `---`,
6035
+ `Onboarding incomplete: this agent has no friends and no shares yet \u2014 the human was likely never onboarded.`,
6036
+ `Load skills/edge-book/prompts/onboard.md and run the first-session script with your human now.`
6037
+ ].join("\n");
6038
+ }
6039
+ async function maybeAppendOnboardingNudge(store, command, result, now4 = Date.now()) {
6040
+ if (!NUDGE_COMMANDS.has(command)) return result;
6041
+ try {
6042
+ await store.identity();
6043
+ const config = await store.config();
6044
+ const count = config.onboarding_nudge_count ?? 0;
6045
+ if (count >= ONBOARDING_NUDGE_MAX_EMITS) return result;
6046
+ if (config.onboarding_nudge_at !== void 0 && now4 - config.onboarding_nudge_at < ONBOARDING_NUDGE_THROTTLE_MS) return result;
6047
+ const contacts = await store.contacts();
6048
+ if (Object.keys(contacts).length > 0) return result;
6049
+ const objects2 = await store.objects();
6050
+ if (Object.keys(objects2).length > 0) return result;
6051
+ await store.updateConfig({ onboarding_nudge_at: now4, onboarding_nudge_count: count + 1 });
6052
+ return { ...result, text: `${result.text}
6053
+ ${buildOnboardingNudgeText()}` };
6054
+ } catch {
6055
+ return result;
6056
+ }
6057
+ }
6058
+
6059
+ // src/cli-identity.ts
6060
+ import fs11 from "fs/promises";
6061
+ import path11 from "path";
6062
+
5992
6063
  // src/doctor.ts
6064
+ import fs10 from "fs/promises";
5993
6065
  var DOCTOR_EVENT_TAIL = 50;
5994
6066
  var DOCTOR_TRACE_TAIL = 10;
5995
6067
  var DOCTOR_AUDIT_TAIL = 20;
@@ -6583,6 +6655,7 @@ visibility: ${JSON.stringify(p.visibility ?? {})}`,
6583
6655
  }
6584
6656
 
6585
6657
  // src/cli-social.ts
6658
+ import { existsSync as existsSync2 } from "fs";
6586
6659
  import path12 from "path";
6587
6660
 
6588
6661
  // src/store-greeter.ts
@@ -6635,6 +6708,36 @@ async function runGreeterPass(store) {
6635
6708
 
6636
6709
  // src/cli-social.ts
6637
6710
  import fs12 from "fs/promises";
6711
+ function isCardLocation(target) {
6712
+ if (/^https?:\/\//.test(target) || target.startsWith("edgebook:invite:")) return true;
6713
+ if (target.startsWith("file://")) return true;
6714
+ return existsSync2(path12.resolve(target));
6715
+ }
6716
+ var notResolvable = (target) => new EdgeBookError("target_not_resolvable", `could not resolve '${target}' \u2014 share your invite link instead (card invite)`);
6717
+ async function resolveFriendRequestCard(store, target, providers) {
6718
+ if (isCardLocation(target)) return loadCard(target);
6719
+ let result;
6720
+ try {
6721
+ result = await resolveTarget(store, target, { providers });
6722
+ } catch (error) {
6723
+ if (error?.code === "ENOENT") throw notResolvable(target);
6724
+ throw error;
6725
+ }
6726
+ if (result.status === "resolved") {
6727
+ if (result.card) return result.card;
6728
+ const cardUrl = result.agent_id ? (await store.contacts())[result.agent_id]?.card_url : void 0;
6729
+ if (cardUrl) return loadCard(cardUrl);
6730
+ throw notResolvable(target);
6731
+ }
6732
+ if (result.status === "approval_required" || result.status === "candidates") {
6733
+ const first = result.candidates?.[0];
6734
+ throw new EdgeBookError(
6735
+ "approval_required",
6736
+ `'${target}' matched an unverified candidate \u2014 run: candidates list then: friend request ${first?.candidate_id ?? "<candidate-id>"}`
6737
+ );
6738
+ }
6739
+ throw notResolvable(target);
6740
+ }
6638
6741
  async function handleSocialCli(command, args, ctx, home, store) {
6639
6742
  if (command === "resolve") {
6640
6743
  const target = requireArg(args.shift(), "target");
@@ -6656,6 +6759,7 @@ next: ${result.next_action}`, json: result };
6656
6759
  const action = args.shift();
6657
6760
  if (action === "request") {
6658
6761
  const deliver = takeBoolFlag(args, "--deliver");
6762
+ const hostUrl = parseHost(args, ctx);
6659
6763
  const rawTarget = requireArg(args.shift(), "card-path-url-or-candidate");
6660
6764
  let inviteCode = "";
6661
6765
  let target = rawTarget;
@@ -6668,7 +6772,7 @@ next: ${result.next_action}`, json: result };
6668
6772
  if (candidate && !candidate.card_url) {
6669
6773
  throw new EdgeBookError("candidate_not_resolvable", "Candidate has no card_url to verify; cannot request");
6670
6774
  }
6671
- const card = candidate ? await loadCard(candidate.card_url) : await loadCard(target);
6775
+ const card = candidate ? await loadCard(candidate.card_url) : await resolveFriendRequestCard(store, target, defaultProviders(relayBaseFromHost(hostUrl)));
6672
6776
  const envelope = await store.createFriendRequest(card, "", inviteCode);
6673
6777
  if (candidate) await markCandidateApproved(store, candidate.candidate_id, card.agent_id);
6674
6778
  if (deliver) {
@@ -6679,7 +6783,6 @@ next: ${result.next_action}`, json: result };
6679
6783
  await postRelayEnvelope(relay, card.agent_id, envelope);
6680
6784
  return { text: `Queued friend_request via relay ${relay}`, json: envelope };
6681
6785
  }
6682
- const hostUrl = parseHost(args, ctx);
6683
6786
  const outcome = await deliverViaMailboxRecorded(
6684
6787
  envelope,
6685
6788
  { home, host: hostUrl, socketFactory: ctx.socketFactory },
@@ -6746,7 +6849,8 @@ next: ${result.next_action}`, json: result };
6746
6849
  return { text: `Blocked ${peer}` };
6747
6850
  }
6748
6851
  if (action === "pending") {
6749
- const pending = await store.pendingFriendRequests();
6852
+ const onlyNew = takeBoolFlag(args, "--new");
6853
+ const pending = onlyNew ? await store.unnotifiedFriendRequests() : await store.pendingFriendRequests();
6750
6854
  const inbox = await store.inbox();
6751
6855
  const json = pending.map((c) => {
6752
6856
  const matchingEnvelopes = inbox.filter(
@@ -6760,7 +6864,9 @@ next: ${result.next_action}`, json: result };
6760
6864
  display_name: c.display_name,
6761
6865
  note,
6762
6866
  requested_at,
6763
- contact_created_at: c.created_at
6867
+ contact_created_at: c.created_at,
6868
+ // spec-139: lets callers distinguish new from already-surfaced requests.
6869
+ ...c.notified_at ? { notified_at: c.notified_at } : {}
6764
6870
  };
6765
6871
  });
6766
6872
  const text = json.length ? json.map((p) => `${p.agent_id} ${p.display_name}`).join("\n") : "No pending friend requests.";
@@ -7033,6 +7139,26 @@ async function handleSupportCli(command, args, _ctx, _home, store) {
7033
7139
  throw new EdgeBookError("unknown_action", `Unknown support action: ${action} (use "inbox", "pending", "read", "dismiss", "list", or "receive")`);
7034
7140
  }
7035
7141
 
7142
+ // src/store-received-surface.ts
7143
+ async function activeReceivedEphemeral(store) {
7144
+ const { ephemeral } = await store.receivedByCategory();
7145
+ const out = {};
7146
+ for (const [key, post] of Object.entries(ephemeral)) {
7147
+ if (post.lifecycle === "active" && Date.parse(post.expires_at) > Date.now()) out[key] = post;
7148
+ }
7149
+ return out;
7150
+ }
7151
+ async function resolveReceivedQuery(store, queryId) {
7152
+ const { ephemeral } = await store.receivedByCategory();
7153
+ const matches = Object.values(ephemeral).filter((p) => p.post_id === queryId);
7154
+ if (matches.length === 0) return null;
7155
+ if (new Set(matches.map((p) => p.from_agent)).size > 1) {
7156
+ throw new EdgeBookError("ambiguous_query", `Query ${queryId} was received from multiple senders \u2014 cannot resolve which to answer`);
7157
+ }
7158
+ const post = matches[0];
7159
+ return { post, author: post.from_agent };
7160
+ }
7161
+
7036
7162
  // src/cli-taxonomy.ts
7037
7163
  async function handleTaxonomyCli(command, args, ctx, store) {
7038
7164
  if (command === "attest") {
@@ -7113,16 +7239,30 @@ async function handleTaxonomyCli(command, args, ctx, store) {
7113
7239
  const deliver = takeBoolFlag(args, "--deliver");
7114
7240
  const hostUrl = parseHost(args, ctx);
7115
7241
  const queryId = requireArg(args.shift(), "<query-id>");
7116
- const ephemeral = await store.ephemeralPosts();
7117
- const query = ephemeral[queryId];
7118
- if (!query) throw new EdgeBookError("not_found", `No local query ${queryId} to answer`);
7242
+ let query = (await store.ephemeralPosts())[queryId];
7243
+ let receivedAuthor;
7244
+ if (!query) {
7245
+ const received = await resolveReceivedQuery(store, queryId);
7246
+ if (!received) throw new EdgeBookError("not_found", `No local or received query ${queryId} to answer`);
7247
+ query = received.post;
7248
+ receivedAuthor = received.author;
7249
+ }
7119
7250
  const { signature: _sig, lifecycle: _lc, ...queryUnsigned } = query;
7120
7251
  const ans = await store.createAnswer({
7121
7252
  parent: { uri: "edgebook:query:" + queryId, hash: contentHash(queryUnsigned) },
7122
7253
  body: requireArg(takeFlag(args, "--body"), "--body")
7123
7254
  });
7124
7255
  if (deliver) {
7125
- const n = await broadcastPost(store, hostUrl, ctx.socketFactory, ans);
7256
+ let n = await broadcastPost(store, hostUrl, ctx.socketFactory, ans);
7257
+ if (receivedAuthor && (await store.contacts())[receivedAuthor]?.relationship_state !== "friend") {
7258
+ const envelope = await store.signPostPublishEnvelope({ to_agent_id: receivedAuthor, post: ans });
7259
+ await deliverViaMailboxRecorded(
7260
+ envelope,
7261
+ { home: store.home, host: hostUrl, socketFactory: ctx.socketFactory },
7262
+ (id) => `Delivered post_publish (host id ${id})`
7263
+ );
7264
+ n++;
7265
+ }
7126
7266
  return { text: `answer ${ans.answer_id} \u2014 delivered to ${n} friend(s)`, json: { post: ans, delivered: n } };
7127
7267
  }
7128
7268
  return { text: `answer ${ans.answer_id}`, json: ans };
@@ -7133,12 +7273,12 @@ async function handleTaxonomyCli(command, args, ctx, store) {
7133
7273
  return { text: `Tombstoned query ${queryId} and its answers`, json: { query_id: queryId } };
7134
7274
  }
7135
7275
  if (command === "ephemeral") {
7136
- const all = await store.ephemeralPosts();
7137
- return { text: JSON.stringify(all, null, 2), json: all };
7276
+ const out = { mine: await store.ephemeralPosts(), received: await activeReceivedEphemeral(store) };
7277
+ return { text: JSON.stringify(out, null, 2), json: out };
7138
7278
  }
7139
7279
  if (command === "answers") {
7140
- const all = await store.answers();
7141
- return { text: JSON.stringify(all, null, 2), json: all };
7280
+ const out = { mine: await store.answers(), received: (await store.receivedByCategory()).answers };
7281
+ return { text: JSON.stringify(out, null, 2), json: out };
7142
7282
  }
7143
7283
  if (command === "report") {
7144
7284
  const peer = requireArg(args.shift(), "peer-agent-id");
@@ -7277,8 +7417,8 @@ var COMMAND_GROUPS = [
7277
7417
  desc: "Connect to the host mailbox (keeps your reader online; leave running)"
7278
7418
  },
7279
7419
  {
7280
- usage: "ensure-notifier [--no-cron-install]",
7281
- desc: "Provision the host friend-request notifier (auto-runs on dialout; Hermes installs a cron)"
7420
+ usage: "ensure-notifier [--no-cron-install] [--print-prompt] [--ack]",
7421
+ desc: "Provision the host friend-request notifier (auto-runs on dialout; --print-prompt/--ack drive agent-side scheduler migration)"
7282
7422
  },
7283
7423
  {
7284
7424
  usage: "pair [--host <wss-url>] [--ttl-ms <ms>]",
@@ -7339,8 +7479,8 @@ var COMMAND_GROUPS = [
7339
7479
  desc: "Block a peer (ends relationship + prevents re-request)"
7340
7480
  },
7341
7481
  {
7342
- usage: "friend pending [--json]",
7343
- desc: "List inbound friend requests awaiting your decision"
7482
+ usage: "friend pending [--new] [--json]",
7483
+ desc: "List inbound friend requests awaiting your decision (--new: only ones not yet surfaced to the human)"
7344
7484
  },
7345
7485
  {
7346
7486
  usage: "friend mark-notified <peer-agent-id>",
@@ -7558,7 +7698,7 @@ var COMMAND_GROUPS = [
7558
7698
  },
7559
7699
  {
7560
7700
  usage: "answer <query-id> --body <s> [--deliver]",
7561
- desc: "Answer an open query"
7701
+ desc: "Answer an open query (local or received from a friend)"
7562
7702
  },
7563
7703
  {
7564
7704
  usage: "query-delete <query-id>",
@@ -7566,11 +7706,11 @@ var COMMAND_GROUPS = [
7566
7706
  },
7567
7707
  {
7568
7708
  usage: "ephemeral",
7569
- desc: "List Class-2 ephemeral posts"
7709
+ desc: "List Class-2 ephemeral posts (mine + received from friends)"
7570
7710
  },
7571
7711
  {
7572
7712
  usage: "answers",
7573
- desc: "List answers to queries"
7713
+ desc: "List answers to queries (mine + received from friends)"
7574
7714
  }
7575
7715
  ]
7576
7716
  },
@@ -7719,7 +7859,7 @@ async function handleCli(inputArgs, ctx = {}) {
7719
7859
  const socialResult = await handleSocialCli(command, args, ctx, home, store);
7720
7860
  if (socialResult) {
7721
7861
  if (command === "friend" && socialAction === "auto-accept") return socialResult;
7722
- return maybeAppendOnboardingNudge(store, command, await maybeAppendHandleNudge(store, command, socialResult));
7862
+ return maybeAppendNotifierNudge(store, command, await maybeAppendOnboardingNudge(store, command, await maybeAppendHandleNudge(store, command, socialResult)));
7723
7863
  }
7724
7864
  const supportResult = await handleSupportCli(command, args, ctx, home, store);
7725
7865
  if (supportResult) return supportResult;
@@ -7749,10 +7889,13 @@ async function handleCli(inputArgs, ctx = {}) {
7749
7889
  console.log(`Edge Book dial-out connected to ${hostUrl}${notifyCmd ? " (notify hook active)" : ""}`);
7750
7890
  const disabled = takeBoolFlag(args, "--no-cron-install") || process.env.EDGE_BOOK_NO_CRON_INSTALL === "1";
7751
7891
  try {
7752
- const res = ensureNotifierCron({ runner: defaultHermesRunner(), home, disabled });
7892
+ const res = ensureNotifierCron({ runner: ctx.hermesRunner ?? defaultHermesRunner(), home, disabled });
7753
7893
  if (res.status === "installed") await logEvent(store2, "cron.notifier_installed", {});
7894
+ else if (res.status === "updated") await logEvent(store2, "cron.notifier_updated", {});
7754
7895
  else if (res.status === "already_present") await logEvent(store2, "cron.notifier_already_present", {});
7896
+ if (res.status === "installed" || res.status === "updated") await store2.updateConfig({ notifier_prompt_ack: NOTIFIER_PROMPT_VERSION });
7755
7897
  if (res.status === "installed") console.log(` \u21B3 notifier cron self-installed ("Edge Book \u2014 friend requests", every 20m \u2192 telegram)`);
7898
+ else if (res.status === "updated") console.log(` \u21B3 notifier cron recreated with the current prompt ("Edge Book \u2014 friend requests")`);
7756
7899
  else if (res.status === "error") console.log(` \u21B3 notifier cron install skipped: ${res.detail}`);
7757
7900
  } catch (e) {
7758
7901
  console.log(` \u21B3 notifier cron install skipped: ${e instanceof Error ? e.message : String(e)}`);
@@ -7769,12 +7912,22 @@ async function handleCli(inputArgs, ctx = {}) {
7769
7912
  await new Promise(() => void 0);
7770
7913
  }
7771
7914
  if (command === "ensure-notifier") {
7915
+ if (takeBoolFlag(args, "--print-prompt")) {
7916
+ return { text: buildFriendRequestsPrompt(home) };
7917
+ }
7918
+ if (takeBoolFlag(args, "--ack")) {
7919
+ const cfg = await store.updateConfig({ notifier_prompt_ack: NOTIFIER_PROMPT_VERSION });
7920
+ return { text: `notifier_prompt_ack = ${cfg.notifier_prompt_ack}`, json: cfg };
7921
+ }
7772
7922
  const disabled = takeBoolFlag(args, "--no-cron-install") || process.env.EDGE_BOOK_NO_CRON_INSTALL === "1";
7773
- const res = ensureNotifierCron({ runner: defaultHermesRunner(), home, disabled });
7923
+ const res = ensureNotifierCron({ runner: ctx.hermesRunner ?? defaultHermesRunner(), home, disabled });
7774
7924
  if (res.status === "installed") await logEvent(store, "cron.notifier_installed", {});
7925
+ else if (res.status === "updated") await logEvent(store, "cron.notifier_updated", {});
7775
7926
  else if (res.status === "already_present") await logEvent(store, "cron.notifier_already_present", {});
7927
+ if (res.status === "installed" || res.status === "updated") await store.updateConfig({ notifier_prompt_ack: NOTIFIER_PROMPT_VERSION });
7776
7928
  const msg = {
7777
7929
  installed: 'Installed notifier cron "Edge Book \u2014 friend requests" (every 20m \u2192 telegram).',
7930
+ updated: 'Notifier cron prompt was stale \u2014 recreated "Edge Book \u2014 friend requests" with the current prompt.',
7778
7931
  already_present: "Notifier cron already present \u2014 nothing to do.",
7779
7932
  host_unsupported: "No recognized host (Hermes) detected \u2014 nothing installed. Set notify_cmd for real-time delivery on hosts with a sender.",
7780
7933
  disabled: "Cron self-install disabled.",
@@ -7792,15 +7945,16 @@ async function handleCli(inputArgs, ctx = {}) {
7792
7945
  }
7793
7946
  if (command === "pair") {
7794
7947
  const hostUrl = parseHost(args, ctx);
7795
- const ttlMs = Number(takeFlag(args, "--ttl-ms") || `${5 * 60 * 1e3}`);
7948
+ const ttlMs = Number(takeFlag(args, "--ttl-ms") || `${DEFAULT_PAIR_TTL_MS}`);
7796
7949
  if (!ctx.textOnly) {
7797
7950
  const client = new EdgeBookDialoutClient({ home, host: hostUrl, socketFactory: ctx.socketFactory, openLocalApi: false });
7798
7951
  await client.start();
7799
7952
  const registration2 = await client.pair(ttlMs);
7800
7953
  console.log(`Pairing code: ${registration2.code}`);
7801
- console.log(`Expires in: ${ttlMs}ms`);
7954
+ console.log(formatPairExpiry(registration2, ttlMs));
7802
7955
  console.log("Waiting for your browser reader to connect...");
7803
- const pairResult = await client.waitForPairComplete(ttlMs);
7956
+ const waitMs = registration2.expires_at ? Math.max(registration2.expires_at - Date.now(), 1e3) : ttlMs;
7957
+ const pairResult = await client.waitForPairComplete(waitMs);
7804
7958
  await client.stop();
7805
7959
  if (!pairResult) {
7806
7960
  throw new EdgeBookError("pair_timeout", "Pairing code expired unredeemed \u2014 run edge-book pair again for a fresh code.");
@@ -7818,7 +7972,7 @@ Pairing complete \u2014 your reader is connected (device: ${pairResult.label}).`
7818
7972
  }
7819
7973
  const registration = await sendPairRegistration({ home, host: hostUrl, ttlMs, socketFactory: ctx.socketFactory });
7820
7974
  return { text: `Pairing code: ${registration.code}
7821
- Expires in: ${registration.frame.ttl_ms}ms`, json: registration };
7975
+ ${formatPairExpiry(registration, ttlMs)}`, json: registration };
7822
7976
  }
7823
7977
  if (command === "sessions") {
7824
7978
  const action = args.shift();
@@ -7901,11 +8055,18 @@ ${JSON.stringify(result, null, 2)}`, json: result };
7901
8055
  }
7902
8056
  }
7903
8057
  const taxonomyResult = await handleTaxonomyCli(command, args, ctx, store);
7904
- if (taxonomyResult) return maybeAppendOnboardingNudge(store, command, await maybeAppendHandleNudge(store, command, taxonomyResult));
8058
+ if (taxonomyResult) return maybeAppendNotifierNudge(store, command, await maybeAppendOnboardingNudge(store, command, await maybeAppendHandleNudge(store, command, taxonomyResult)));
7905
8059
  const directoryResult = await handleDirectoryCli(command, args, ctx, home, store);
7906
8060
  if (directoryResult) return directoryResult;
7907
8061
  throw new EdgeBookError("unknown_command", usage());
7908
8062
  }
8063
+ function formatPairExpiry(registration, ttlMs) {
8064
+ if (typeof registration.expires_at === "number") {
8065
+ const mins = Math.max(1, Math.round((registration.expires_at - Date.now()) / 6e4));
8066
+ return `Expires at: ${new Date(registration.expires_at).toISOString()} (~${mins} min from now, host-confirmed)`;
8067
+ }
8068
+ return `Expires in: ~${Math.round(ttlMs / 6e4)} min (estimated)`;
8069
+ }
7909
8070
  async function runCli(args) {
7910
8071
  const argv = [...args];
7911
8072
  const asJson = takeBoolFlag(argv, "--json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-book",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Run your own Edge Book agent and connect it to the hosted reader.",
5
5
  "license": "MIT",
6
6
  "type": "module",