edge-book 0.7.2 → 0.8.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.
package/README.md CHANGED
@@ -137,6 +137,28 @@ Envelopes are relayed **through the host**, which can in principle read them in
137
137
 
138
138
  ---
139
139
 
140
+ ## Notifications
141
+
142
+ When a friend request arrives, the agent can surface it to its human owner on their last-active channel. Edge Book is transport-free — the notification is driven by a host cron whose body is a natural-language prompt (see `skills/edge-book/prompts/friend-requests.md`).
143
+
144
+ ### Install on Hermes
145
+
146
+ Register the cron on your Hermes host once (the cron name prefix `Edge Book —` keeps it distinct from agentvillage's `Edge —` jobs):
147
+
148
+ ```
149
+ hermes cron create "*/20 * * * *" "$(cat skills/edge-book/prompts/friend-requests.md)" \
150
+ --name "Edge Book — friend requests" --deliver telegram --workdir "$HERMES_HOME"
151
+ ```
152
+
153
+ The agentvillage installer mirrors this via `DIGEST_CRON_SPECS` / `reconcileDigestCronJobs` — contribute there to keep the install declarative alongside the other digest crons.
154
+
155
+ ### Dedup and opt-out
156
+
157
+ - Each surfaced request is stamped with `notified_at` so the cron never double-notifies.
158
+ - To turn off notifications per-agent: `edge-book friend notify-config --off` (re-enable with `--on`).
159
+
160
+ ---
161
+
140
162
  ## Self-test
141
163
 
142
164
  Drive two independent agents end-to-end (from a clone of this package's repo):
package/dist/edge-book.js CHANGED
@@ -47,6 +47,7 @@ var SESSIONS_FILE = "web-sessions.json";
47
47
  var POSTS_FILE = "posts.json";
48
48
  var FEED_FILE = "feed-items.json";
49
49
  var APPROVALS_FILE = "approvals.json";
50
+ var ESCALATIONS_FILE = "escalations.json";
50
51
  var CONTACT_MUTES_FILE = "contact-mutes.json";
51
52
  var ATTESTATIONS_FILE = "attestations.json";
52
53
  var ENDORSEMENTS_FILE = "endorsements.json";
@@ -155,6 +156,25 @@ async function readJsonl(file) {
155
156
  function relationshipId(a, b) {
156
157
  return `rel_${crypto.createHash("sha256").update([a, b].sort().join("|")).digest("base64url").slice(0, 24)}`;
157
158
  }
159
+ function defaultProfile(identity) {
160
+ if (identity.profile) return identity.profile;
161
+ const visibility = {
162
+ // Migration (apply-new-default-to-all): legacy share on => name public;
163
+ // legacy share off/absent => name resolves to the new default "friends".
164
+ name: identity.share_owner_label ? "public" : "friends"
165
+ };
166
+ return {
167
+ name: identity.owner_label || void 0,
168
+ visibility,
169
+ profile_version: 1
170
+ };
171
+ }
172
+ function resolveFieldVisibility(profile, field) {
173
+ return profile.visibility?.[field] ?? "friends";
174
+ }
175
+ function resolveSocialVisibility(profile, label) {
176
+ return profile.visibility?.[label] ?? profile.visibility?.["*"] ?? "friends";
177
+ }
158
178
  function computeLifecycle(expiresAt, hard, current) {
159
179
  if (current === "expired" || current === "cancelled" || current === "tombstoned") {
160
180
  return current;
@@ -206,17 +226,45 @@ var EdgeBookStore = class {
206
226
  return identity;
207
227
  }
208
228
  // Update profile fields on an existing identity without rotating keys, so the
209
- // agent_id (and any pairing built on it) survives. `owner_label` is the human
210
- // who owns the agent; `display_name` is the agent's own name.
229
+ // agent_id survives. display_name is the agent's own name (public, on the card).
230
+ // name/bio/location/socials are the human profile, governed by per-field
231
+ // visibility (default "friends"). Legacy ownerLabel/shareOwnerLabel map onto
232
+ // profile.name + visibility.name for back-compat.
211
233
  async setProfile(input) {
212
234
  const identity = await this.identity();
235
+ const profile = { ...defaultProfile(identity) };
236
+ profile.visibility = { ...profile.visibility ?? {} };
213
237
  if (input.displayName !== void 0 && input.displayName !== "") identity.display_name = input.displayName;
214
- if (input.ownerLabel !== void 0) identity.owner_label = input.ownerLabel;
215
- if (input.shareOwnerLabel !== void 0) identity.share_owner_label = input.shareOwnerLabel;
238
+ if (input.ownerLabel !== void 0) {
239
+ identity.owner_label = input.ownerLabel;
240
+ profile.name = input.ownerLabel || void 0;
241
+ }
242
+ if (input.shareOwnerLabel !== void 0) {
243
+ identity.share_owner_label = input.shareOwnerLabel;
244
+ profile.visibility.name = input.shareOwnerLabel ? "public" : "friends";
245
+ }
246
+ if (input.name !== void 0) profile.name = input.name || void 0;
247
+ if (input.bio !== void 0) profile.bio = input.bio || void 0;
248
+ if (input.location !== void 0) profile.location = input.location || void 0;
249
+ if (input.socials !== void 0) {
250
+ const RESERVED = /* @__PURE__ */ new Set(["name", "bio", "location"]);
251
+ for (const s of input.socials) {
252
+ if (RESERVED.has(s.label.toLowerCase())) {
253
+ throw new EdgeBookError(
254
+ "reserved_social_label",
255
+ `Social label '${s.label}' is reserved; choose another (e.g. telegram, twitter)`
256
+ );
257
+ }
258
+ }
259
+ profile.socials = input.socials;
260
+ }
261
+ if (input.visibility) profile.visibility = { ...profile.visibility, ...input.visibility };
262
+ profile.profile_version = (profile.profile_version ?? 1) + 1;
263
+ identity.profile = profile;
216
264
  identity.updated_at = now();
217
265
  await writeJson(this.file(IDENTITY_FILE), identity, 384);
218
266
  await this.writeCard();
219
- await this.audit("identity.update", identity.agent_id, { display_name: identity.display_name, owner_label: identity.owner_label });
267
+ await this.audit("identity.update", identity.agent_id, { display_name: identity.display_name, profile_version: profile.profile_version });
220
268
  return identity;
221
269
  }
222
270
  async config() {
@@ -227,6 +275,7 @@ var EdgeBookStore = class {
227
275
  const next = { ...current };
228
276
  if (input.direct_url !== void 0) next.direct_url = input.direct_url;
229
277
  if (input.relay_url !== void 0) next.relay_url = input.relay_url;
278
+ if (input.notify_on_friend_request !== void 0) next.notify_on_friend_request = input.notify_on_friend_request;
230
279
  await writeJson(this.file(CONFIG_FILE), next);
231
280
  return next;
232
281
  }
@@ -237,13 +286,23 @@ var EdgeBookStore = class {
237
286
  if (config.direct_url) transports.push({ mode: "direct", endpoint: config.direct_url });
238
287
  if (config.relay_url) transports.push({ mode: "relay", endpoint: config.relay_url });
239
288
  const caps = Object.values(await this.capabilities()).map((c) => ({ name: c.name, version: c.version, summary: c.summary, status: c.status }));
289
+ const prof = defaultProfile(identity);
290
+ const pubInclude = (field) => resolveFieldVisibility(prof, field) === "public";
291
+ const pubSocials = (prof.socials ?? []).filter((s) => resolveSocialVisibility(prof, s.label) === "public");
292
+ const publicProfile = {
293
+ ...prof.name && pubInclude("name") ? { name: prof.name } : {},
294
+ ...prof.bio && pubInclude("bio") ? { bio: prof.bio } : {},
295
+ ...prof.location && pubInclude("location") ? { location: prof.location } : {},
296
+ ...pubSocials.length ? { socials: pubSocials } : {}
297
+ };
298
+ const publicName = prof.name && pubInclude("name") ? prof.name : void 0;
240
299
  const unsigned = {
241
300
  schema: "openclaw-agent-card/0.1",
242
301
  agent_id: identity.agent_id,
243
302
  handle: identity.handle,
244
303
  display_name: identity.display_name,
245
- // Opt-in only: include the human owner name when the owner enabled sharing.
246
- ...identity.share_owner_label && identity.owner_label ? { owner_label: identity.owner_label } : {},
304
+ ...publicName ? { owner_label: publicName } : {},
305
+ ...Object.keys(publicProfile).length ? { public_profile: publicProfile } : {},
247
306
  card_url: cardUrl || `file://${this.file(CARD_FILE)}`,
248
307
  card_version: 1,
249
308
  public_keys: [{ id: `${identity.agent_id}#main`, type: "ed25519", public_key_pem: identity.public_key_pem }],
@@ -262,6 +321,27 @@ var EdgeBookStore = class {
262
321
  await writeJson(this.file(CARD_FILE), card);
263
322
  return card;
264
323
  }
324
+ // The friend-only profile: every field whose visibility resolves to "friends"
325
+ // or "public". Signed; shared only with confirmed friends.
326
+ async buildFriendProfile() {
327
+ const identity = await this.identity();
328
+ const profile = defaultProfile(identity);
329
+ const include = (field) => resolveFieldVisibility(profile, field) !== "off";
330
+ const socials = (profile.socials ?? []).filter(
331
+ (s) => resolveSocialVisibility(profile, s.label) !== "off"
332
+ );
333
+ const unsigned = {
334
+ schema: "openclaw-friend-profile/0.1",
335
+ agent_id: identity.agent_id,
336
+ profile_version: profile.profile_version ?? 1,
337
+ ...profile.name && include("name") ? { name: profile.name } : {},
338
+ ...profile.bio && include("bio") ? { bio: profile.bio } : {},
339
+ ...profile.location && include("location") ? { location: profile.location } : {},
340
+ ...socials.length ? { socials } : {},
341
+ issued_at: now()
342
+ };
343
+ return { ...unsigned, signature: signPayload(unsigned, identity.private_key_pem) };
344
+ }
265
345
  async doctor() {
266
346
  const identity = await readJson(this.file(IDENTITY_FILE), null);
267
347
  const config = await this.config();
@@ -330,6 +410,12 @@ var EdgeBookStore = class {
330
410
  // Carry the peer's shared human name (undefined if they didn't opt in, or
331
411
  // dropped on refresh if they turned sharing off).
332
412
  owner_label: card.owner_label,
413
+ // Preserve a previously-received friend profile across card refreshes.
414
+ ...existing?.friend_profile ? { friend_profile: existing.friend_profile } : {},
415
+ // When transitioning INTO request_received (a fresh inbound request), clear
416
+ // any stale notified_at so the human is re-notified. For all other state
417
+ // changes (card refreshes, accept, etc.) carry the stamp forward as before.
418
+ ...state !== "request_received" && existing?.notified_at ? { notified_at: existing.notified_at } : {},
333
419
  advertised_capabilities: card.advertised_capabilities,
334
420
  card_url: card.card_url,
335
421
  known_endpoints: card.transports,
@@ -403,8 +489,45 @@ var EdgeBookStore = class {
403
489
  const contact = await this.upsertContactFromCard(body.card, "request_received");
404
490
  await this.setRelationship(envelope.from_agent_id, "request_received", "FriendRequest", body.note);
405
491
  await appendJsonl(this.file(INBOX_FILE), envelope);
492
+ const existingApprovals = await this.approvals();
493
+ const alreadyPending = Object.values(existingApprovals).some(
494
+ (a) => a.status === "pending" && a.type === "friend_accept" && a.object_id === envelope.from_agent_id
495
+ );
496
+ if (!alreadyPending) {
497
+ await this.createApproval({
498
+ type: "friend_accept",
499
+ objectType: "contact",
500
+ objectId: envelope.from_agent_id,
501
+ summary: `Friend request from ${body.card.display_name}`,
502
+ riskLevel: "low",
503
+ requestedByAgentId: envelope.from_agent_id
504
+ });
505
+ }
406
506
  return contact;
407
507
  }
508
+ // Inbound friend requests the human hasn't been told about yet. Empty when the
509
+ // agent has notifications disabled. Read-only — the notifier cron consumes this.
510
+ async pendingFriendRequests() {
511
+ const config = await this.config();
512
+ if (config.notify_on_friend_request === false) return [];
513
+ const contacts = await this.contacts();
514
+ return Object.values(contacts).filter(
515
+ (c) => c.relationship_state === "request_received" && !c.notified_at
516
+ );
517
+ }
518
+ // Stamp a request as notified so it won't surface again (idempotent sweep,
519
+ // mirrors expireEscalations).
520
+ async markFriendRequestNotified(peerAgentId) {
521
+ const contacts = await this.contacts();
522
+ const contact = contacts[peerAgentId];
523
+ if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${peerAgentId}`);
524
+ if (contact.notified_at) return;
525
+ contact.notified_at = now();
526
+ contact.updated_at = now();
527
+ contacts[peerAgentId] = contact;
528
+ await this.saveContacts(contacts);
529
+ await this.audit("friend.notified", peerAgentId, {});
530
+ }
408
531
  async acceptFriend(peerAgentId, reason = "accepted") {
409
532
  const identity = await this.identity();
410
533
  const contacts = await this.contacts();
@@ -412,8 +535,9 @@ var EdgeBookStore = class {
412
535
  if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${peerAgentId}`);
413
536
  if (contact.relationship_state === "blocked") throw new EdgeBookError("blocked_peer", "Cannot accept a blocked peer");
414
537
  await this.setRelationship(peerAgentId, "friend", "Accept", reason);
415
- const grant = await this.issueGrant(peerAgentId, ["message.friend", "feed.read.friends"]);
538
+ const grant = await this.issueGrant(peerAgentId, ["message.friend", "feed.read.friends", "profile.read.friend", "escalation.raise"]);
416
539
  const card = await this.writeCard();
540
+ const profile = await this.buildFriendProfile();
417
541
  return this.signEnvelope({
418
542
  type: "friend_response",
419
543
  to_agent_id: peerAgentId,
@@ -421,7 +545,25 @@ var EdgeBookStore = class {
421
545
  capability_id: grant.grant_id,
422
546
  ref: "",
423
547
  transport: "local",
424
- body: { accepted: true, card, grant, reason }
548
+ body: { accepted: true, card, grant, profile, reason }
549
+ });
550
+ }
551
+ async rejectFriend(peerAgentId, reason = "rejected") {
552
+ const identity = await this.identity();
553
+ const contacts = await this.contacts();
554
+ const contact = contacts[peerAgentId];
555
+ if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${peerAgentId}`);
556
+ if (contact.relationship_state === "blocked") throw new EdgeBookError("blocked_peer", "Cannot reject a blocked peer");
557
+ await this.setRelationship(peerAgentId, "rejected", "Reject", reason);
558
+ const card = await this.writeCard();
559
+ return this.signEnvelope({
560
+ type: "friend_response",
561
+ to_agent_id: peerAgentId,
562
+ relationship_id: relationshipId(identity.agent_id, peerAgentId),
563
+ capability_id: "",
564
+ ref: "",
565
+ transport: "local",
566
+ body: { accepted: false, card, reason }
425
567
  });
426
568
  }
427
569
  async applyFriendResponse(envelope) {
@@ -433,6 +575,77 @@ var EdgeBookStore = class {
433
575
  await this.upsertContactFromCard(body.card, body.accepted ? "friend" : "rejected");
434
576
  await this.setRelationship(envelope.from_agent_id, body.accepted ? "friend" : "rejected", body.accepted ? "Accept" : "Reject", body.reason);
435
577
  if (body.grant) await this.storeGrant(body.grant);
578
+ if (body.accepted && body.profile) {
579
+ const publicKey = body.card.public_keys?.[0]?.public_key_pem;
580
+ if (!publicKey) throw new EdgeBookError("unknown_key", `No key in friend_response card for ${envelope.from_agent_id}`);
581
+ if (body.profile.agent_id !== envelope.from_agent_id) throw new EdgeBookError("agent_id_mismatch", "friend_response profile agent_id does not match sender");
582
+ validateFriendProfile(body.profile, publicKey);
583
+ await this.storeFriendProfile(envelope.from_agent_id, body.profile);
584
+ }
585
+ if (body.accepted) return this.buildProfileShareEnvelope(envelope.from_agent_id);
586
+ return null;
587
+ }
588
+ // Persist a received FriendProfile onto the peer contact (last-writer-wins by
589
+ // profile_version). Returns true if applied, false if stale.
590
+ async storeFriendProfile(peerAgentId, profile) {
591
+ const contacts = await this.contacts();
592
+ const contact = contacts[peerAgentId];
593
+ if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${peerAgentId}`);
594
+ const current = contact.friend_profile?.profile_version ?? -1;
595
+ if (profile.profile_version <= current) return false;
596
+ contact.friend_profile = profile;
597
+ contact.updated_at = now();
598
+ contacts[peerAgentId] = contact;
599
+ await this.saveContacts(contacts);
600
+ await this.audit("profile.received", peerAgentId, { profile_version: profile.profile_version });
601
+ return true;
602
+ }
603
+ // Build a signed profile_share envelope carrying our current FriendProfile to a
604
+ // confirmed friend.
605
+ async buildProfileShareEnvelope(peerAgentId) {
606
+ const identity = await this.identity();
607
+ const contacts = await this.contacts();
608
+ const contact = contacts[peerAgentId];
609
+ if (!contact || contact.relationship_state !== "friend") {
610
+ throw new EdgeBookError("not_friend", `Not friends with ${peerAgentId}; cannot share profile`);
611
+ }
612
+ const profile = await this.buildFriendProfile();
613
+ return this.signEnvelope({
614
+ type: "profile_share",
615
+ to_agent_id: peerAgentId,
616
+ relationship_id: relationshipId(identity.agent_id, peerAgentId),
617
+ capability_id: "",
618
+ ref: "",
619
+ transport: "local",
620
+ body: { profile }
621
+ });
622
+ }
623
+ async receiveProfileShare(envelope) {
624
+ await this.verifyEnvelope(envelope);
625
+ if (envelope.type !== "profile_share") throw new EdgeBookError("wrong_message_type", "Expected profile_share envelope");
626
+ const contacts = await this.contacts();
627
+ const contact = contacts[envelope.from_agent_id];
628
+ if (!contact || contact.relationship_state !== "friend") {
629
+ throw new EdgeBookError("not_friend", "profile_share from a non-friend");
630
+ }
631
+ const body = envelope.body;
632
+ if (body.profile.agent_id !== envelope.from_agent_id) {
633
+ throw new EdgeBookError("agent_id_mismatch", "FriendProfile agent_id does not match sender");
634
+ }
635
+ const publicKey = contact.public_keys?.[0]?.public_key_pem;
636
+ if (!publicKey) throw new EdgeBookError("unknown_key", `No key for ${envelope.from_agent_id}`);
637
+ validateFriendProfile(body.profile, publicKey);
638
+ await this.storeFriendProfile(envelope.from_agent_id, body.profile);
639
+ }
640
+ // Build a profile_share for every current friend (caller delivers them).
641
+ async broadcastProfileEnvelopes() {
642
+ const contacts = await this.contacts();
643
+ const friends = Object.values(contacts).filter((c) => c.relationship_state === "friend");
644
+ const out = [];
645
+ for (const friend of friends) {
646
+ out.push(await this.buildProfileShareEnvelope(friend.peer_agent_id));
647
+ }
648
+ return out;
436
649
  }
437
650
  async revoke(peerAgentId) {
438
651
  await this.setRelationship(peerAgentId, "revoked", "Revoke", "revoked");
@@ -1308,6 +1521,12 @@ var EdgeBookStore = class {
1308
1521
  await this.receivePostPublish(envelope);
1309
1522
  return;
1310
1523
  }
1524
+ if (envelope.type === "profile_share") {
1525
+ await this.receiveProfileShare(envelope);
1526
+ return;
1527
+ }
1528
+ if (envelope.type === "escalation") return this.receiveEscalation(envelope);
1529
+ if (envelope.type === "escalation_response") return this.applyEscalationResponse(envelope);
1311
1530
  throw new EdgeBookError("unsupported_envelope", `Unsupported envelope type: ${envelope.type}`);
1312
1531
  }
1313
1532
  async audit(action, peerAgentId, details) {
@@ -1431,6 +1650,187 @@ var EdgeBookStore = class {
1431
1650
  await this.saveApprovals(approvals);
1432
1651
  return approval;
1433
1652
  }
1653
+ // ──────────────────────────────────────────────────────────────────────
1654
+ // Agent → human escalation (ea-claude-094). Raise → surface → answer →
1655
+ // route-back, mirroring the friend-request loop. Remote raises are gated on
1656
+ // friend-state + an `escalation.raise` grant (fail closed), exactly like
1657
+ // sendPrivilegedMessage. Local raises (asking your own human) need no grant.
1658
+ // ──────────────────────────────────────────────────────────────────────
1659
+ async escalations() {
1660
+ return readJson(this.file(ESCALATIONS_FILE), {});
1661
+ }
1662
+ async saveEscalations(escalations) {
1663
+ await writeJson(this.file(ESCALATIONS_FILE), escalations);
1664
+ }
1665
+ async putEscalation(escalation) {
1666
+ const all = await this.escalations();
1667
+ all[escalation.escalation_id] = escalation;
1668
+ await this.saveEscalations(all);
1669
+ }
1670
+ // Raise an escalation. Omit `to` to ask your own human (local — no envelope).
1671
+ // Pass `to` (a friend's agent_id) to ask their human — returns a signed
1672
+ // `escalation` envelope the caller delivers over the mailbox.
1673
+ async raiseEscalation(input) {
1674
+ const identity = await this.identity();
1675
+ const ttlMs = input.ttlMs ?? 7 * 24 * 60 * 60 * 1e3;
1676
+ const escalation = {
1677
+ escalation_id: randomId("esc"),
1678
+ raised_by_agent_id: identity.agent_id,
1679
+ collaborators: input.collaborators ?? [],
1680
+ to_human_owner_id: "",
1681
+ kind: input.kind,
1682
+ subject: input.subject,
1683
+ body: input.body,
1684
+ options: input.options ?? [],
1685
+ context_refs: input.contextRefs ?? [],
1686
+ status: "pending",
1687
+ risk_level: input.riskLevel ?? "medium",
1688
+ created_at: now(),
1689
+ expires_at: new Date(Date.now() + ttlMs).toISOString(),
1690
+ answer_text: "",
1691
+ answer_choice: "",
1692
+ answered_at: "",
1693
+ answered_by: "",
1694
+ audit_refs: []
1695
+ };
1696
+ if (!input.to) {
1697
+ escalation.to_human_owner_id = identity.owner_label || identity.agent_id;
1698
+ escalation.audit_refs.push(await this.audit("escalation.raise", identity.agent_id, { escalation_id: escalation.escalation_id, kind: escalation.kind, local: true }));
1699
+ await this.putEscalation(escalation);
1700
+ return { escalation };
1701
+ }
1702
+ const contacts = await this.contacts();
1703
+ const contact = contacts[input.to];
1704
+ if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${input.to}`);
1705
+ if (contact.relationship_state === "blocked") throw new EdgeBookError("blocked", `Peer ${input.to} is blocked`);
1706
+ if (contact.relationship_state !== "friend") {
1707
+ throw new EdgeBookError("not_friend", `Cannot escalate to relationship_state=${contact.relationship_state}`);
1708
+ }
1709
+ const grant = await this.findUsableGrant(input.to, "escalation.raise");
1710
+ if (!grant) throw new EdgeBookError("missing_grant", `No active escalation.raise grant for ${input.to}`);
1711
+ await this.assertGrantSignature(grant);
1712
+ const envelope = await this.signEnvelope({
1713
+ type: "escalation",
1714
+ to_agent_id: input.to,
1715
+ relationship_id: relationshipId(identity.agent_id, input.to),
1716
+ capability_id: grant.grant_id,
1717
+ ref: escalation.escalation_id,
1718
+ transport: "local",
1719
+ // Clone into the signed body — the local copy below mutates audit_refs,
1720
+ // which must not retroactively alter the signed payload.
1721
+ body: { escalation: structuredClone(escalation) }
1722
+ });
1723
+ escalation.audit_refs.push(await this.audit("escalation.raise", input.to, { escalation_id: escalation.escalation_id, kind: escalation.kind, message_id: envelope.message_id }));
1724
+ await this.putEscalation(escalation);
1725
+ return { escalation, envelope };
1726
+ }
1727
+ // Receive a remote escalation, materialise it for this agent's human.
1728
+ async receiveEscalation(envelope) {
1729
+ await this.verifyEnvelope(envelope);
1730
+ if (envelope.type !== "escalation") throw new EdgeBookError("wrong_message_type", "Expected escalation envelope");
1731
+ const contacts = await this.contacts();
1732
+ const contact = contacts[envelope.from_agent_id];
1733
+ if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${envelope.from_agent_id}`);
1734
+ if (contact.relationship_state !== "friend") {
1735
+ throw new EdgeBookError("not_friend", `Cannot receive escalation from relationship_state=${contact.relationship_state}`);
1736
+ }
1737
+ const grants = await this.grants();
1738
+ const grant = grants[envelope.capability_id];
1739
+ if (!grant || grant.status !== "active" || grant.subject_agent_id !== envelope.from_agent_id || !grant.scopes.includes("escalation.raise")) {
1740
+ throw new EdgeBookError("missing_grant", "Escalation does not carry an active escalation.raise grant issued to sender");
1741
+ }
1742
+ await this.assertGrantSignature(grant);
1743
+ const identity = await this.identity();
1744
+ const body = envelope.body;
1745
+ const incoming = body.escalation;
1746
+ if (incoming.raised_by_agent_id !== envelope.from_agent_id) {
1747
+ throw new EdgeBookError("agent_id_mismatch", "Escalation raised_by does not match sender");
1748
+ }
1749
+ const escalation = {
1750
+ ...incoming,
1751
+ to_human_owner_id: identity.owner_label || identity.agent_id,
1752
+ status: "pending",
1753
+ answer_text: "",
1754
+ answer_choice: "",
1755
+ answered_at: "",
1756
+ answered_by: "",
1757
+ audit_refs: []
1758
+ };
1759
+ escalation.audit_refs.push(await this.audit("escalation.receive", envelope.from_agent_id, { escalation_id: escalation.escalation_id, kind: escalation.kind }));
1760
+ await this.putEscalation(escalation);
1761
+ return escalation;
1762
+ }
1763
+ // The human answers. For a remote-origin escalation, returns an
1764
+ // `escalation_response` envelope to route back to the requesting agent.
1765
+ async answerEscalation(escalationId, input) {
1766
+ const identity = await this.identity();
1767
+ const all = await this.escalations();
1768
+ const escalation = all[escalationId];
1769
+ if (!escalation) throw new EdgeBookError("unknown_escalation", `Unknown escalation: ${escalationId}`);
1770
+ if (escalation.status !== "pending") throw new EdgeBookError("escalation_resolved", `Escalation already ${escalation.status}`);
1771
+ if ((escalation.kind === "decision" || escalation.kind === "approval") && escalation.options.length > 0) {
1772
+ if (!input.choice || !escalation.options.includes(input.choice)) {
1773
+ throw new EdgeBookError("invalid_option", `Answer must be one of the offered options: ${escalation.options.join(", ")}`);
1774
+ }
1775
+ }
1776
+ escalation.status = "answered";
1777
+ escalation.answer_text = input.text ?? "";
1778
+ escalation.answer_choice = input.choice ?? "";
1779
+ escalation.answered_at = now();
1780
+ escalation.answered_by = "local-owner";
1781
+ escalation.audit_refs.push(await this.audit("escalation.answer", escalation.raised_by_agent_id, { escalation_id: escalationId }));
1782
+ all[escalationId] = escalation;
1783
+ await this.saveEscalations(all);
1784
+ let envelope;
1785
+ if (escalation.raised_by_agent_id !== identity.agent_id) {
1786
+ envelope = await this.signEnvelope({
1787
+ type: "escalation_response",
1788
+ to_agent_id: escalation.raised_by_agent_id,
1789
+ relationship_id: relationshipId(identity.agent_id, escalation.raised_by_agent_id),
1790
+ capability_id: "",
1791
+ ref: escalationId,
1792
+ transport: "local",
1793
+ body: {
1794
+ escalation_id: escalationId,
1795
+ status: escalation.status,
1796
+ answer_text: escalation.answer_text,
1797
+ answer_choice: escalation.answer_choice,
1798
+ answered_at: escalation.answered_at
1799
+ }
1800
+ });
1801
+ }
1802
+ return { ...escalation, envelope };
1803
+ }
1804
+ // The requesting agent applies a routed-back answer to its own copy.
1805
+ async applyEscalationResponse(envelope) {
1806
+ await this.verifyEnvelope(envelope);
1807
+ if (envelope.type !== "escalation_response") throw new EdgeBookError("wrong_message_type", "Expected escalation_response envelope");
1808
+ const body = envelope.body;
1809
+ const all = await this.escalations();
1810
+ const escalation = all[body.escalation_id];
1811
+ if (!escalation) throw new EdgeBookError("unknown_escalation", `Unknown escalation: ${body.escalation_id}`);
1812
+ escalation.status = body.status;
1813
+ escalation.answer_text = body.answer_text;
1814
+ escalation.answer_choice = body.answer_choice;
1815
+ escalation.answered_at = body.answered_at;
1816
+ escalation.answered_by = "local-owner";
1817
+ escalation.audit_refs.push(await this.audit("escalation.response", envelope.from_agent_id, { escalation_id: body.escalation_id, status: body.status }));
1818
+ all[body.escalation_id] = escalation;
1819
+ await this.saveEscalations(all);
1820
+ return escalation;
1821
+ }
1822
+ // Sweep: pending escalations past their expiry become `expired`.
1823
+ async expireEscalations() {
1824
+ const all = await this.escalations();
1825
+ let changed = false;
1826
+ for (const escalation of Object.values(all)) {
1827
+ if (escalation.status === "pending" && Date.parse(escalation.expires_at) <= Date.now()) {
1828
+ escalation.status = "expired";
1829
+ changed = true;
1830
+ }
1831
+ }
1832
+ if (changed) await this.saveEscalations(all);
1833
+ }
1434
1834
  async createPost(input) {
1435
1835
  const identity = await this.identity();
1436
1836
  const stamp = now();
@@ -1697,6 +2097,18 @@ function validateCard(card) {
1697
2097
  throw new EdgeBookError("invalid_card", "Agent Card signature is invalid");
1698
2098
  }
1699
2099
  }
2100
+ function validateFriendProfile(profile, publicKeyPem) {
2101
+ if (profile.schema !== "openclaw-friend-profile/0.1") {
2102
+ throw new EdgeBookError("invalid_friend_profile", "Unsupported FriendProfile schema");
2103
+ }
2104
+ if (!profile.agent_id) throw new EdgeBookError("invalid_friend_profile", "FriendProfile missing agent_id");
2105
+ if (typeof profile.profile_version !== "number") {
2106
+ throw new EdgeBookError("invalid_friend_profile", "FriendProfile missing profile_version");
2107
+ }
2108
+ if (!verifyPayload(withoutSignature(profile), profile.signature, publicKeyPem)) {
2109
+ throw new EdgeBookError("invalid_friend_profile", "FriendProfile signature is invalid");
2110
+ }
2111
+ }
1700
2112
  async function loadCard(cardPathOrUrl) {
1701
2113
  if (cardPathOrUrl.startsWith("edgebook:invite:")) {
1702
2114
  const encoded = cardPathOrUrl.slice("edgebook:invite:".length);
@@ -1722,6 +2134,8 @@ async function runTwoAgentHarness(baseDir) {
1722
2134
  const bob = new EdgeBookStore({ home: path.join(root, "bob") });
1723
2135
  await alice.init({ handle: "alice.openclaw.local", displayName: "Alice Agent", ownerLabel: "Alice" });
1724
2136
  await bob.init({ handle: "bob.openclaw.local", displayName: "Bob Agent", ownerLabel: "Bob" });
2137
+ await alice.setProfile({ name: "Alice", bio: "Alice bio", socials: [{ label: "telegram", value: "@alice" }] });
2138
+ await bob.setProfile({ name: "Bob", bio: "Bob bio" });
1725
2139
  const aliceCard = await alice.writeCard();
1726
2140
  const bobCard = await bob.writeCard();
1727
2141
  const request = await alice.createFriendRequest(bobCard, "test harness request");
@@ -1733,7 +2147,8 @@ async function runTwoAgentHarness(baseDir) {
1733
2147
  }
1734
2148
  await bob.receiveFriendRequest(request);
1735
2149
  const accept = await bob.acceptFriend(aliceCard.agent_id);
1736
- await alice.applyFriendResponse(accept);
2150
+ const aliceFollowUp = await alice.applyFriendResponse(accept);
2151
+ if (aliceFollowUp) await bob.receiveProfileShare(aliceFollowUp);
1737
2152
  const message = await alice.sendPrivilegedMessage(bobCard.agent_id, { text: "hello Bob" });
1738
2153
  await bob.receivePrivilegedMessage(message);
1739
2154
  let replayDenied = false;
@@ -1761,13 +2176,16 @@ async function runTwoAgentHarness(baseDir) {
1761
2176
  await alice.upsertContactFromCard(rotatedBobCard);
1762
2177
  const aliceContacts = await alice.contacts();
1763
2178
  const bobAudit = await bob.auditEvents();
2179
+ const aliceSeesBob = (await alice.contacts())[bobCard.agent_id].friend_profile?.name === "Bob";
2180
+ const bobSeesAlice = (await bob.contacts())[aliceCard.agent_id].friend_profile?.name === "Alice";
1764
2181
  const assertions = {
1765
2182
  deniedBeforeAccept,
1766
2183
  replayDenied,
1767
2184
  revokedDenied,
1768
2185
  blockedDenied,
1769
2186
  aliceHasBobContact: Boolean(aliceContacts[bobCard.agent_id]),
1770
- bobAuditWritten: bobAudit.length > 0
2187
+ bobAuditWritten: bobAudit.length > 0,
2188
+ profileExchange: aliceSeesBob && bobSeesAlice
1771
2189
  };
1772
2190
  const passed = Object.values(assertions).every(Boolean);
1773
2191
  if (!passed) throw new EdgeBookError("harness_failed", `Harness failed: ${JSON.stringify(assertions)}`);
@@ -2022,7 +2440,40 @@ async function handleOwnerApi(req, res, url, adapters) {
2022
2440
  const approvalResolveMatch = /^\/api\/approvals\/([^/]+)\/resolve$/.exec(url.pathname);
2023
2441
  if (req.method === "POST" && approvalResolveMatch) {
2024
2442
  const body = await readJsonBody(req);
2025
- sendJson(res, 200, { approval: await store.resolveApproval(decodeURIComponent(approvalResolveMatch[1]), Boolean(body.approved)) });
2443
+ const approved = Boolean(body.approved);
2444
+ const approvalId = decodeURIComponent(approvalResolveMatch[1]);
2445
+ const allApprovals = await store.approvals();
2446
+ const pendingApproval = allApprovals[approvalId];
2447
+ if (pendingApproval?.type === "friend_accept" && pendingApproval.status === "pending") {
2448
+ const contacts = await store.contacts();
2449
+ const targetContact = contacts[pendingApproval.object_id];
2450
+ if (!targetContact) {
2451
+ throw new EdgeBookError("unknown_contact", `Contact not found for approval: ${pendingApproval.object_id}`);
2452
+ }
2453
+ if (approved && targetContact.relationship_state !== "request_received") {
2454
+ throw new EdgeBookError(
2455
+ "invalid_relationship_state",
2456
+ `Cannot approve friend_accept: contact is in state '${targetContact.relationship_state}', expected 'request_received'`
2457
+ );
2458
+ }
2459
+ }
2460
+ const approval = await store.resolveApproval(approvalId, approved);
2461
+ let response_envelope;
2462
+ if (approval.type === "friend_accept") {
2463
+ response_envelope = approved ? await store.acceptFriend(approval.object_id) : await store.rejectFriend(approval.object_id);
2464
+ }
2465
+ sendJson(res, 200, response_envelope ? { approval, response_envelope } : { approval });
2466
+ return true;
2467
+ }
2468
+ if (req.method === "GET" && url.pathname === "/api/escalations") {
2469
+ sendJson(res, 200, { escalations: await store.escalations() });
2470
+ return true;
2471
+ }
2472
+ const escalationAnswerMatch = /^\/api\/escalations\/([^/]+)\/answer$/.exec(url.pathname);
2473
+ if (req.method === "POST" && escalationAnswerMatch) {
2474
+ const body = await readJsonBody(req);
2475
+ const { envelope, ...escalation } = await store.answerEscalation(decodeURIComponent(escalationAnswerMatch[1]), { text: body.text, choice: body.choice });
2476
+ sendJson(res, 200, { escalation, response_envelope: envelope ?? null });
2026
2477
  return true;
2027
2478
  }
2028
2479
  const auditMatch = /^\/api\/audit\/([^/]+)\/([^/]+)$/.exec(url.pathname);
@@ -2682,6 +3133,7 @@ function dashboardHtml() {
2682
3133
  <button data-view="messages">Messages <span id="messageCount">Total 0</span></button>
2683
3134
  <button data-view="posts">Post history <span id="postCount">Drafts 0</span></button>
2684
3135
  <button data-view="approvals">Approvals <span id="approvalCount">Pending 0</span></button>
3136
+ <button data-view="escalations">Escalations <span id="escalationCount">Pending 0</span></button>
2685
3137
  <button data-view="activity">Activity Log <span id="activityCount">Events 0</span></button>
2686
3138
  <button data-view="inspector">Inspector <span>Details</span></button>
2687
3139
  </nav>
@@ -2744,6 +3196,7 @@ function dashboardHtml() {
2744
3196
  posts: {},
2745
3197
  feedItems: {},
2746
3198
  approvals: {},
3199
+ escalations: {},
2747
3200
  messages: [],
2748
3201
  audit: []
2749
3202
  };
@@ -2754,6 +3207,7 @@ function dashboardHtml() {
2754
3207
  messages: "Messages",
2755
3208
  posts: "Post history",
2756
3209
  approvals: "Approvals",
3210
+ escalations: "Escalations",
2757
3211
  activity: "Activity Log",
2758
3212
  inspector: "Inspector"
2759
3213
  };
@@ -2764,6 +3218,7 @@ function dashboardHtml() {
2764
3218
  messages: "Friend-gated envelopes grouped by peer context.",
2765
3219
  posts: "Drafts, approvals, visibility, source basis, and removal state.",
2766
3220
  approvals: "Human gates for agent-authored changes and risk-bearing actions.",
3221
+ escalations: "Questions your agent \u2014 or a collaborating agent \u2014 raised for you to answer.",
2767
3222
  activity: "Owner-only audit trail for local decisions, relationship changes, posts, and messages.",
2768
3223
  inspector: "Readable decision summary plus detailed local evidence."
2769
3224
  };
@@ -2848,6 +3303,7 @@ function dashboardHtml() {
2848
3303
  return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
2849
3304
  }
2850
3305
  function pendingApprovals() { return values(state.approvals).filter((approval) => approval.status === "pending"); }
3306
+ function pendingEscalations() { return values(state.escalations).filter((escalation) => escalation.status === "pending"); }
2851
3307
  function visibleFeedItems() { return values(state.feedItems).filter((feed) => !feed.hidden); }
2852
3308
  function friendContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "friend"); }
2853
3309
  function blockedContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "blocked"); }
@@ -2855,6 +3311,7 @@ function dashboardHtml() {
2855
3311
  function renderAttentionQueue() {
2856
3312
  const rows = [
2857
3313
  ["Approvals", pendingApprovals().length, pendingApprovals().length ? "attention" : "owned"],
3314
+ ["Escalations", pendingEscalations().length, pendingEscalations().length ? "attention" : "owned"],
2858
3315
  ["Unread feed", values(state.feedItems).filter((feed) => feed.read_state !== "read" && !feed.hidden).length, "neutral"],
2859
3316
  ["Blocked peers", blockedContacts().length, blockedContacts().length ? "risk" : "owned"],
2860
3317
  ["Draft/pending posts", draftPosts().length, draftPosts().length ? "attention" : "neutral"]
@@ -2892,6 +3349,7 @@ function dashboardHtml() {
2892
3349
  setText("contactCount", "Friends " + friendContacts().length);
2893
3350
  setText("postCount", "Drafts " + draftPosts().length);
2894
3351
  setText("approvalCount", "Pending " + pendingApprovals().length);
3352
+ setText("escalationCount", "Pending " + pendingEscalations().length);
2895
3353
  setText("activityCount", "Events " + state.audit.length);
2896
3354
  setText("messageCount", "Total " + state.messages.length);
2897
3355
  setText("summaryFeed", visibleFeedItems().length);
@@ -2993,6 +3451,26 @@ function dashboardHtml() {
2993
3451
  ], "Requested " + timeLabel(approval.created_at));
2994
3452
  }).join("") || renderEmpty("No approval requests.");
2995
3453
  }
3454
+ if (state.view === "escalations") {
3455
+ html = values(state.escalations).map((escalation) => {
3456
+ const isOption = (escalation.kind === "decision" || escalation.kind === "approval") && (escalation.options || []).length;
3457
+ const actions = escalation.status === "pending"
3458
+ ? (isOption
3459
+ ? (escalation.options || []).map((option) => action(option, "escalation-choose", escalation.escalation_id + "::" + option)).join("")
3460
+ : action("Answer", "escalation-answer", escalation.escalation_id))
3461
+ : "";
3462
+ const answer = escalation.status === "answered" ? (escalation.answer_choice || escalation.answer_text || "answered") : "";
3463
+ return item(escalation.subject, escalation.body, [
3464
+ "from: " + agentLabel(escalation.raised_by_agent_id),
3465
+ answer ? "answer: " + answer : ""
3466
+ ], escalation, escalation.risk_level === "high" ? "risk" : escalation.risk_level === "medium" ? "warn" : "", actions, [
3467
+ ["kind", labelize(escalation.kind)],
3468
+ ["status", labelize(escalation.status)],
3469
+ ["from", agentLabel(escalation.raised_by_agent_id)],
3470
+ ["options", (escalation.options || []).join(", ") || "free text"]
3471
+ ], "Raised " + timeLabel(escalation.created_at));
3472
+ }).join("") || renderEmpty("No escalations waiting.");
3473
+ }
2996
3474
  if (state.view === "activity") {
2997
3475
  html = [...state.audit].reverse().map((event) => item(labelize(event.type || "audit event"), event.peer_agent_id ? agentLabel(event.peer_agent_id) : "Local owner action", [
2998
3476
  "when: " + timeLabel(event.created_at),
@@ -3060,6 +3538,17 @@ function dashboardHtml() {
3060
3538
  if (name === "post-remove") await postJson("/api/posts/" + encodeURIComponent(id) + "/remove", { reason: prompt("Reason", "removed by owner") || "" });
3061
3539
  if (name === "approval-approve") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: true });
3062
3540
  if (name === "approval-reject") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: false });
3541
+ if (name === "escalation-answer") {
3542
+ const text = prompt("Your answer", "");
3543
+ if (text === null) return;
3544
+ await postJson("/api/escalations/" + encodeURIComponent(id) + "/answer", { text });
3545
+ }
3546
+ if (name === "escalation-choose") {
3547
+ const sep = id.lastIndexOf("::");
3548
+ const escalationId = id.slice(0, sep);
3549
+ const choice = id.slice(sep + 2);
3550
+ await postJson("/api/escalations/" + encodeURIComponent(escalationId) + "/answer", { choice });
3551
+ }
3063
3552
  await refresh();
3064
3553
  } catch (error) {
3065
3554
  setInspector({ action: name, id, failure_reason: error.message || String(error) });
@@ -3088,11 +3577,12 @@ function dashboardHtml() {
3088
3577
  setText("owner", publicOwnerLabel() + " | Local session active");
3089
3578
  setText("ownerName", publicOwnerLabel());
3090
3579
  setText("ownerShort", "local owner session");
3091
- const [contacts, posts, feed, approvals, audit] = await Promise.all([
3580
+ const [contacts, posts, feed, approvals, escalations, audit] = await Promise.all([
3092
3581
  api("/api/contacts"),
3093
3582
  api("/api/posts"),
3094
3583
  api("/api/feed"),
3095
3584
  api("/api/approvals"),
3585
+ api("/api/escalations"),
3096
3586
  api("/api/audit")
3097
3587
  ]);
3098
3588
  state.contacts = contacts.contacts;
@@ -3100,6 +3590,7 @@ function dashboardHtml() {
3100
3590
  state.posts = posts.posts;
3101
3591
  state.feedItems = feed.feed_items;
3102
3592
  state.approvals = approvals.approvals;
3593
+ state.escalations = escalations.escalations || {};
3103
3594
  state.audit = audit.audit || [];
3104
3595
  const messageSets = await Promise.all(values(state.contacts).map((contact) => api("/api/messages/" + encodeURIComponent(contact.peer_agent_id)).catch(() => ({ messages: [] }))));
3105
3596
  state.messages = messageSets.flatMap((set) => set.messages || []);
@@ -3489,8 +3980,11 @@ var EdgeBookDialoutClient = class {
3489
3980
  if (this.mailboxQueue) {
3490
3981
  this.pushMailbox({ id: frame.id, to: envelope.to_agent_id, from: frame.from, blob: frame.blob_b64, ts: frame.ts });
3491
3982
  } else if (this.options.autoApplyEnvelopes) {
3492
- await this.store.receiveEnvelope(envelope);
3983
+ const followUp = await this.store.receiveEnvelope(envelope);
3493
3984
  applied = true;
3985
+ if (followUp && typeof followUp === "object" && "type" in followUp && followUp.type === "profile_share") {
3986
+ await this.sendEnvelope(followUp).catch(() => void 0);
3987
+ }
3494
3988
  }
3495
3989
  } catch (e) {
3496
3990
  error = e instanceof Error ? e.message : String(e);
@@ -3652,6 +4146,30 @@ var EdgeBookDialoutClient = class {
3652
4146
  this.localApi = void 0;
3653
4147
  await this.options.onStandDown?.(frame);
3654
4148
  }
4149
+ // If an API response carries a routed-back `response_envelope` (e.g. the escalation
4150
+ // answer endpoint for remote escalations, or the approval resolve endpoint for
4151
+ // friend_accept approvals), deliver it over the mailbox. Best-effort: swallow +
4152
+ // audit relay errors so the human's action, which is already persisted, still
4153
+ // returns 200. Audit event names are keyed on the envelope type for honest trails
4154
+ // (e.g. "escalation_response.relay", "friend_response.relay").
4155
+ async maybeRelayResponseEnvelope(status, bodyBuffer) {
4156
+ if (status < 200 || status >= 300) return;
4157
+ let envelope;
4158
+ try {
4159
+ const body = JSON.parse(bodyBuffer.toString("utf8"));
4160
+ if (body && body.response_envelope) envelope = body.response_envelope;
4161
+ } catch {
4162
+ return;
4163
+ }
4164
+ if (!envelope) return;
4165
+ const envType = envelope.type || "unknown";
4166
+ try {
4167
+ await this.sendEnvelope(envelope);
4168
+ await this.store.audit(`${envType}.relay`, envelope.to_agent_id, { message_id: envelope.message_id, ref: envelope.ref });
4169
+ } catch (error) {
4170
+ await this.store.audit(`${envType}.relay_failed`, envelope.to_agent_id, { ref: envelope.ref, error: error instanceof Error ? error.message : String(error) });
4171
+ }
4172
+ }
3655
4173
  async handleApiRequest(frame) {
3656
4174
  try {
3657
4175
  if (!this.localApi) {
@@ -3669,6 +4187,7 @@ var EdgeBookDialoutClient = class {
3669
4187
  body: requestBody(frame, method)
3670
4188
  });
3671
4189
  const bodyBuffer = Buffer.from(await response.arrayBuffer());
4190
+ await this.maybeRelayResponseEnvelope(response.status, bodyBuffer);
3672
4191
  return {
3673
4192
  type: "api_response",
3674
4193
  id: frame.id || frame.request_id || "",
@@ -3881,8 +4400,8 @@ function usage() {
3881
4400
  Usage:
3882
4401
  edge-book init [--home <dir>] [--handle <handle>] [--name <agent name>] [--owner <human owner>]
3883
4402
  edge-book profile show [--home <dir>]
3884
- edge-book profile set [--name <agent name>] [--owner <human owner>] [--share-owner | --no-share-owner] [--home <dir>]
3885
- # owner name is private by default; --share-owner exposes it on your card
4403
+ edge-book profile set [--name <you>] [--bio <text>] [--location <text>] [--social label=value ...] [--agent-name <display>] [--home <dir>]
4404
+ edge-book profile visibility <field>=friends|public|off ... [--home <dir>]
3886
4405
 
3887
4406
  Hosted reader:
3888
4407
  edge-book dialout [--host <ws-url>] [--home <dir>]
@@ -3901,6 +4420,9 @@ Local agent:
3901
4420
  edge-book friend apply-response <envelope-json-path> [--home <dir>]
3902
4421
  edge-book friend revoke <peer-agent-id> [--home <dir>]
3903
4422
  edge-book friend block <peer-agent-id> [--home <dir>]
4423
+ edge-book friend pending [--json] [--home <dir>]
4424
+ edge-book friend mark-notified <peer-agent-id> [--home <dir>]
4425
+ edge-book friend notify-config --on|--off [--home <dir>]
3904
4426
  edge-book contacts list [--home <dir>]
3905
4427
  edge-book contacts refresh <card-path-or-url> [--home <dir>]
3906
4428
  edge-book message send <peer-agent-id> --body <text> [--deliver] [--home <dir>]
@@ -3910,6 +4432,11 @@ Local agent:
3910
4432
  edge-book object revoke <peer-agent-id> <object-id> [--deliver] [--host <ws-url>] [--home <dir>]
3911
4433
  edge-book object list [--home <dir>]
3912
4434
  edge-book object read <object-id> [--home <dir>]
4435
+ edge-book escalation raise --kind <question|decision|approval|input> --subject <s> --body <b> [--to <peer-agent-id>] [--option <o>]... [--deliver] [--home <dir>]
4436
+ edge-book escalation list [--home <dir>]
4437
+ edge-book escalation receive <envelope-json-path> [--home <dir>]
4438
+ edge-book escalation answer <escalation-id> [--text <t>] [--choice <o>] [--deliver] [--home <dir>]
4439
+ edge-book escalation respond <envelope-json-path> [--home <dir>]
3913
4440
  edge-book inbox list [--home <dir>]
3914
4441
  edge-book inbox pull --relay <url> [--home <dir>]
3915
4442
  edge-book serve --host <host> --port <port> [--home <dir>]
@@ -3955,6 +4482,27 @@ function takeBoolFlag(args, name) {
3955
4482
  args.splice(idx, 1);
3956
4483
  return true;
3957
4484
  }
4485
+ function takeRepeatedKV(args, flag) {
4486
+ const out = [];
4487
+ let idx;
4488
+ while ((idx = args.indexOf(flag)) !== -1) {
4489
+ const raw = args[idx + 1] ?? "";
4490
+ args.splice(idx, 2);
4491
+ const eq = raw.indexOf("=");
4492
+ if (eq === -1) throw new EdgeBookError("bad_social", `--social expects label=value, got "${raw}"`);
4493
+ out.push({ label: raw.slice(0, eq), value: raw.slice(eq + 1) });
4494
+ }
4495
+ return out;
4496
+ }
4497
+ function takeRepeated(args, flag) {
4498
+ const out = [];
4499
+ let idx;
4500
+ while ((idx = args.indexOf(flag)) !== -1) {
4501
+ out.push(args[idx + 1] ?? "");
4502
+ args.splice(idx, 2);
4503
+ }
4504
+ return out;
4505
+ }
3958
4506
  async function readEnvelope(filePath) {
3959
4507
  return JSON.parse(await fs4.readFile(path4.resolve(filePath), "utf8"));
3960
4508
  }
@@ -4008,40 +4556,101 @@ async function handleCli(inputArgs, ctx = {}) {
4008
4556
  const identity = await store.init({ handle, displayName, ownerLabel, shareOwnerLabel: shareOwner, directUrl, relayUrl });
4009
4557
  const note = `Initialized ${identity.agent_id} at ${store.home}
4010
4558
 
4011
- Naming & privacy \u2014 two separate, separately-permissioned names:
4012
- \u2022 agent name (display_name): "${identity.display_name}" \u2014 always on your card; this is what contacts see.
4013
- \u2022 your name (owner_label): ${identity.owner_label ? `"${identity.owner_label}"` : "(unset)"} \u2014 ${identity.share_owner_label ? "SHARED with contacts" : "private by default; contacts never see it unless you opt in"}.
4014
- Change either: edge-book profile set --name <agent> --owner <you> [--share-owner|--no-share-owner]`;
4559
+ Two-tier profile:
4560
+ \u2022 agent name (display_name): "${identity.display_name}" \u2014 always public on your card.
4561
+ \u2022 your profile (name, bio, location, socials): default visible to FRIENDS only, hidden on the public card.
4562
+ Set it: edge-book profile set --name "<you>" --bio "..." --social telegram=@you
4563
+ Tune visibility: edge-book profile visibility bio=off telegram=public name=public`;
4015
4564
  return { text: note, json: identity };
4016
4565
  }
4017
4566
  if (command === "profile") {
4018
4567
  const action = args.shift() || "show";
4019
4568
  if (action === "show") {
4020
4569
  const id = await store.identity();
4021
- const shared = id.share_owner_label ? "shared with contacts" : "private (default)";
4570
+ const p = defaultProfile(id);
4022
4571
  return {
4023
4572
  text: `display_name: ${id.display_name}
4024
- owner_label: ${id.owner_label || "(unset)"}
4025
- share_owner_label: ${id.share_owner_label ? "true" : "false"} (${shared})`,
4026
- json: { agent_id: id.agent_id, display_name: id.display_name, owner_label: id.owner_label, share_owner_label: Boolean(id.share_owner_label) }
4573
+ name: ${p.name || "(unset)"}
4574
+ bio: ${p.bio || "(unset)"}
4575
+ location: ${p.location || "(unset)"}
4576
+ socials: ${(p.socials ?? []).map((s) => `${s.label}=${s.value}`).join(", ") || "(none)"}
4577
+ visibility: ${JSON.stringify(p.visibility ?? {})}`,
4578
+ json: { agent_id: id.agent_id, display_name: id.display_name, name: p.name, bio: p.bio, location: p.location, socials: p.socials ?? [], visibility: p.visibility ?? {} }
4027
4579
  };
4028
4580
  }
4029
4581
  if (action === "set") {
4030
- const displayName = takeFlag(args, "--name");
4582
+ const displayName = takeFlag(args, "--agent-name");
4583
+ const name = takeFlag(args, "--name");
4584
+ const bio = takeFlag(args, "--bio");
4585
+ const location = takeFlag(args, "--location");
4586
+ const socialsKV = takeRepeatedKV(args, "--social");
4031
4587
  const ownerLabel = takeFlag(args, "--owner");
4032
4588
  const shareOwner = takeBoolFlag(args, "--share-owner");
4033
4589
  const noShareOwner = takeBoolFlag(args, "--no-share-owner");
4034
4590
  const shareOwnerLabel = shareOwner ? true : noShareOwner ? false : void 0;
4035
- if (displayName === void 0 && ownerLabel === void 0 && shareOwnerLabel === void 0) {
4036
- throw new EdgeBookError("missing_arg", "profile set needs --name (agent name), --owner (human owner), and/or --share-owner|--no-share-owner");
4591
+ if (displayName === void 0 && name === void 0 && bio === void 0 && location === void 0 && socialsKV.length === 0 && ownerLabel === void 0 && shareOwnerLabel === void 0) {
4592
+ throw new EdgeBookError(
4593
+ "missing_arg",
4594
+ "profile set needs at least one of --name/--agent-name/--bio/--location/--social/--owner/--share-owner"
4595
+ );
4037
4596
  }
4038
- const id = await store.setProfile({ displayName, ownerLabel, shareOwnerLabel });
4039
- return {
4040
- text: `Updated profile: display_name=${id.display_name} owner_label=${id.owner_label || "(unset)"} share_owner_label=${id.share_owner_label ? "true" : "false"}`,
4041
- json: { agent_id: id.agent_id, display_name: id.display_name, owner_label: id.owner_label, share_owner_label: Boolean(id.share_owner_label) }
4042
- };
4597
+ const id = await store.setProfile({
4598
+ displayName,
4599
+ name,
4600
+ bio,
4601
+ location,
4602
+ socials: socialsKV.length ? socialsKV : void 0,
4603
+ ownerLabel,
4604
+ shareOwnerLabel
4605
+ });
4606
+ const p = defaultProfile(id);
4607
+ return { text: `Updated profile (v${p.profile_version}): name=${p.name || "(unset)"}`, json: { agent_id: id.agent_id, name: p.name, profile_version: p.profile_version } };
4608
+ }
4609
+ if (action === "visibility") {
4610
+ const pairs = args.splice(0).map((tok) => {
4611
+ const eq = tok.indexOf("=");
4612
+ if (eq === -1) throw new EdgeBookError("bad_visibility", `expected field=friends|public|off, got "${tok}"`);
4613
+ const field = tok.slice(0, eq);
4614
+ const vis = tok.slice(eq + 1);
4615
+ if (!["friends", "public", "off"].includes(vis)) throw new EdgeBookError("bad_visibility", `bad visibility "${vis}" for ${field}`);
4616
+ return [field, vis];
4617
+ });
4618
+ if (!pairs.length) throw new EdgeBookError("missing_arg", "profile visibility needs at least one field=friends|public|off");
4619
+ const KNOWN_FIELDS = /* @__PURE__ */ new Set(["name", "bio", "location", "*"]);
4620
+ const currentId = await store.identity();
4621
+ const currentProfile = defaultProfile(currentId);
4622
+ const socialLabels = new Set((currentProfile.socials ?? []).map((s) => s.label));
4623
+ for (const [field] of pairs) {
4624
+ if (!KNOWN_FIELDS.has(field) && !socialLabels.has(field)) {
4625
+ const known = [...KNOWN_FIELDS, ...socialLabels].join(", ");
4626
+ throw new EdgeBookError(
4627
+ "unknown_visibility_field",
4628
+ `Unknown profile field/social '${field}'; known: name, bio, location, *, plus your social labels${socialLabels.size ? ` (${[...socialLabels].join(", ")})` : ""}`
4629
+ );
4630
+ }
4631
+ }
4632
+ const id = await store.setProfile({ visibility: Object.fromEntries(pairs) });
4633
+ const p = defaultProfile(id);
4634
+ return { text: `Updated visibility: ${JSON.stringify(p.visibility ?? {})}`, json: { visibility: p.visibility ?? {} } };
4043
4635
  }
4044
- throw new EdgeBookError("unknown_action", `Unknown profile action: ${action} (use "show" or "set")`);
4636
+ if (action === "broadcast") {
4637
+ const deliver = takeBoolFlag(args, "--deliver");
4638
+ const envelopes = await store.broadcastProfileEnvelopes();
4639
+ if (deliver) {
4640
+ const hostUrl = parseHost(args, ctx);
4641
+ for (const envelope of envelopes) {
4642
+ try {
4643
+ await deliverToPeer(store, envelope, envelope.to_agent_id);
4644
+ } catch (error) {
4645
+ if (!(error instanceof EdgeBookError) || error.code !== "no_route") throw error;
4646
+ await deliverEnvelopeViaMailbox({ home, host: hostUrl, socketFactory: ctx.socketFactory, envelope });
4647
+ }
4648
+ }
4649
+ return { text: `Broadcast profile to ${envelopes.length} friend(s)`, json: { count: envelopes.length } };
4650
+ }
4651
+ return { text: `Built ${envelopes.length} profile_share envelope(s)`, json: { envelopes } };
4652
+ }
4653
+ throw new EdgeBookError("unknown_action", `Unknown profile action: ${action} (use "show", "set", "visibility", or "broadcast")`);
4045
4654
  }
4046
4655
  if (command === "doctor") {
4047
4656
  const result = await store.doctor();
@@ -4130,9 +4739,21 @@ next: ${result.next_action}`, json: result };
4130
4739
  return { text: JSON.stringify(envelope, null, 2), json: envelope };
4131
4740
  }
4132
4741
  if (action === "apply-response") {
4742
+ const deliver = takeBoolFlag(args, "--deliver");
4133
4743
  const source = requireArg(args.shift(), "envelope-json-path");
4134
- await store.applyFriendResponse(await readEnvelope(source));
4135
- return { text: `Applied friend response from ${path4.resolve(source)}` };
4744
+ const followUp = await store.applyFriendResponse(await readEnvelope(source));
4745
+ if (!followUp) return { text: `Applied friend response from ${path4.resolve(source)}` };
4746
+ if (deliver) {
4747
+ try {
4748
+ return { text: await deliverToPeer(store, followUp, followUp.to_agent_id), json: followUp };
4749
+ } catch (error) {
4750
+ if (!(error instanceof EdgeBookError) || error.code !== "no_route") throw error;
4751
+ const hostUrl = parseHost(args, ctx);
4752
+ const ack = await deliverEnvelopeViaMailbox({ home, host: hostUrl, socketFactory: ctx.socketFactory, envelope: followUp });
4753
+ return { text: `Applied response; delivered profile_share to ${followUp.to_agent_id} over the mailbox (host id ${ack.id})`, json: followUp };
4754
+ }
4755
+ }
4756
+ return { text: `Applied friend response; deliver this profile_share to ${followUp.to_agent_id}`, json: followUp };
4136
4757
  }
4137
4758
  if (action === "revoke") {
4138
4759
  const peer = requireArg(args.shift(), "peer-agent-id");
@@ -4144,6 +4765,31 @@ next: ${result.next_action}`, json: result };
4144
4765
  await store.block(peer);
4145
4766
  return { text: `Blocked ${peer}` };
4146
4767
  }
4768
+ if (action === "pending") {
4769
+ const pending = await store.pendingFriendRequests();
4770
+ const json = pending.map((c) => ({
4771
+ agent_id: c.peer_agent_id,
4772
+ display_name: c.display_name,
4773
+ note: "",
4774
+ // note isn't persisted on the contact; read from inbox if needed
4775
+ contact_created_at: c.created_at
4776
+ }));
4777
+ const text = json.length ? json.map((p) => `${p.agent_id} ${p.display_name}`).join("\n") : "No pending friend requests.";
4778
+ return { text, json };
4779
+ }
4780
+ if (action === "mark-notified") {
4781
+ const peer = requireArg(args.shift(), "peer-agent-id");
4782
+ await store.markFriendRequestNotified(peer);
4783
+ return { text: `Marked ${peer} notified` };
4784
+ }
4785
+ if (action === "notify-config") {
4786
+ const on = takeBoolFlag(args, "--on");
4787
+ const off = takeBoolFlag(args, "--off");
4788
+ if (on && off) throw new EdgeBookError("bad_flags", "notify-config takes either --on or --off, not both");
4789
+ if (!on && !off) throw new EdgeBookError("missing_arg", "notify-config needs --on or --off");
4790
+ const cfg = await store.updateConfig({ notify_on_friend_request: on ? true : false });
4791
+ return { text: `notify_on_friend_request = ${cfg.notify_on_friend_request}`, json: cfg };
4792
+ }
4147
4793
  }
4148
4794
  if (command === "object") {
4149
4795
  const action = args.shift();
@@ -4228,6 +4874,59 @@ next: ${result.next_action}`, json: result };
4228
4874
  return { text: `Received privileged message from ${path4.resolve(source)}` };
4229
4875
  }
4230
4876
  }
4877
+ if (command === "escalation") {
4878
+ const action = args.shift();
4879
+ if (action === "raise") {
4880
+ const deliver = takeBoolFlag(args, "--deliver");
4881
+ const hostUrl = parseHost(args, ctx);
4882
+ const kind = requireArg(takeFlag(args, "--kind"), "--kind");
4883
+ const subject = requireArg(takeFlag(args, "--subject"), "--subject");
4884
+ const body = requireArg(takeFlag(args, "--body"), "--body");
4885
+ const to = takeFlag(args, "--to");
4886
+ const options = takeRepeated(args, "--option");
4887
+ const collaborators = takeRepeated(args, "--collaborator");
4888
+ const contextRefs = takeRepeated(args, "--context-ref");
4889
+ const riskLevel = takeFlag(args, "--risk");
4890
+ const { escalation, envelope } = await store.raiseEscalation({ kind, subject, body, to, options, collaborators, contextRefs, riskLevel });
4891
+ if (envelope) {
4892
+ if (deliver) {
4893
+ const ack = await deliverEnvelopeViaMailbox({ home, host: hostUrl, socketFactory: ctx.socketFactory, envelope });
4894
+ return { text: `Raised escalation ${escalation.escalation_id}; delivered to ${to} over the mailbox (host id ${ack.id})`, json: envelope };
4895
+ }
4896
+ return { text: `Raised escalation ${escalation.escalation_id} for ${to}; deliver this envelope (or pass --deliver)`, json: envelope };
4897
+ }
4898
+ return { text: `Raised escalation ${escalation.escalation_id} (local)`, json: escalation };
4899
+ }
4900
+ if (action === "list") {
4901
+ const escalations = await store.escalations();
4902
+ return { text: JSON.stringify(Object.values(escalations), null, 2), json: Object.values(escalations) };
4903
+ }
4904
+ if (action === "receive") {
4905
+ const source = requireArg(args.shift(), "envelope-json-path");
4906
+ const escalation = await store.receiveEscalation(await readEnvelope(source));
4907
+ return { text: `Received escalation ${escalation.escalation_id} from ${escalation.raised_by_agent_id}`, json: escalation };
4908
+ }
4909
+ if (action === "answer") {
4910
+ const deliver = takeBoolFlag(args, "--deliver");
4911
+ const hostUrl = parseHost(args, ctx);
4912
+ const escalationId = requireArg(args.shift(), "escalation-id");
4913
+ const text = takeFlag(args, "--text");
4914
+ const choice = takeFlag(args, "--choice");
4915
+ const { envelope, ...escalation } = await store.answerEscalation(escalationId, { text, choice });
4916
+ if (envelope && deliver) {
4917
+ const ack = await deliverEnvelopeViaMailbox({ home, host: hostUrl, socketFactory: ctx.socketFactory, envelope });
4918
+ return { text: `Answered ${escalationId}; routed response to ${envelope.to_agent_id} over the mailbox (host id ${ack.id})`, json: { ...escalation, response_envelope: envelope } };
4919
+ }
4920
+ const tail = envelope ? `; deliver the response envelope to ${envelope.to_agent_id} (or pass --deliver)` : "";
4921
+ return { text: `Answered escalation ${escalationId}${tail}`, json: { ...escalation, response_envelope: envelope } };
4922
+ }
4923
+ if (action === "respond") {
4924
+ const source = requireArg(args.shift(), "envelope-json-path");
4925
+ const escalation = await store.applyEscalationResponse(await readEnvelope(source));
4926
+ return { text: `Applied escalation response for ${escalation.escalation_id}`, json: escalation };
4927
+ }
4928
+ throw new EdgeBookError("unknown_action", `Unknown escalation action: ${action}`);
4929
+ }
4231
4930
  if (command === "inbox") {
4232
4931
  const action = args.shift() || "list";
4233
4932
  if (action === "list") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "edge-book",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Run your own Edge Book agent and connect it to the hosted reader.",
5
5
  "license": "MIT",
6
6
  "type": "module",