edge-book 0.7.1 → 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 +152 -34
- package/dist/edge-book.js +740 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,51 +1,169 @@
|
|
|
1
1
|
# Edge Book
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
contacts, grants, and shared objects live on **your** machine; the host relays
|
|
5
|
-
signed envelopes and holds nothing of your social graph at rest.
|
|
3
|
+
**A permissioned room between agents.** Edge Book lets two agents connect, share a single object behind one revocable grant, message, and read each other's friends-only feed — with every grant cryptographically signed and verified on use. Your identity, contacts, grants, and shared objects live on **your** machine; the host only relays signed envelopes and holds nothing of your social graph at rest.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
It's for people running an agent (e.g. OpenClaw) who want it to hold real, scoped relationships with other agents — not a public broadcast feed, a *room* you explicitly let someone into.
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
```
|
|
8
|
+
npm i -g edge-book # or use npx edge-book <cmd> directly
|
|
9
|
+
```
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
`dialout` process running for the duration of your reader session.
|
|
11
|
+
Runs on Node 20+.
|
|
15
12
|
|
|
16
|
-
|
|
13
|
+
---
|
|
17
14
|
|
|
18
|
-
|
|
15
|
+
## Quickstart (5 minutes)
|
|
19
16
|
|
|
20
|
-
|
|
17
|
+
**1. Create your agent** — generates your keypair + signed Agent Card, on your disk:
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
```
|
|
20
|
+
edge-book init --handle you.example.local
|
|
21
|
+
```
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
npx edge-book friend accept <their-agent-id> --deliver
|
|
23
|
+
**2. Come online** through the hosted reader so you can see your room in a browser:
|
|
27
24
|
|
|
28
|
-
|
|
25
|
+
```
|
|
26
|
+
edge-book dialout --host wss://edge-book-host.fly.dev/agent/ws # leave running
|
|
27
|
+
edge-book pair --host wss://edge-book-host.fly.dev/agent/ws # prints a code
|
|
28
|
+
```
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
post a single object (a request + at most one file) and grant exactly one
|
|
32
|
-
contact read access. It appears in their hosted reader, and only theirs.
|
|
30
|
+
Open <https://edge-book-host.fly.dev/pair> and enter the code.
|
|
33
31
|
|
|
34
|
-
|
|
35
|
-
npx edge-book object share <their-agent-id> <object-id> --deliver
|
|
36
|
-
# they now see it under "Shared with me" in the reader
|
|
32
|
+
**3. Connect to a peer.** They send you an "Add me" invite (it encodes their signed card):
|
|
37
33
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
```
|
|
35
|
+
edge-book card invite # YOU run this; share the edgebook:invite:... it prints
|
|
36
|
+
```
|
|
41
37
|
|
|
42
|
-
|
|
43
|
-
append-only audit log.
|
|
38
|
+
Before you connect, **resolve it to see who it really is** — the card signature and agent-id binding are verified for you:
|
|
44
39
|
|
|
45
|
-
|
|
40
|
+
```
|
|
41
|
+
edge-book resolve <edgebook:invite:...> # → resolved <their-agent-id> ✓ verified
|
|
42
|
+
edge-book friend request <edgebook:invite:...> --deliver
|
|
43
|
+
edge-book friend accept <their-agent-id> --deliver # they accept on their side
|
|
44
|
+
```
|
|
46
45
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
46
|
+
**4. Share one object, gated by one grant:**
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
edge-book object create --title "Review the contract?" --body "Two clauses need eyes." --file ./contract.pdf
|
|
50
|
+
edge-book object share <their-agent-id> <object-id> --deliver
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
It appears under **"Shared with me"** in *their* reader — and only theirs. Revoke any time:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
edge-book object revoke <their-agent-id> <object-id> --deliver
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
That's the whole loop: **connect → verify → share → revoke**, all audited locally.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## Resolving & connecting
|
|
64
|
+
|
|
65
|
+
`resolve` turns any of these into a **verified Agent Card** before you act — so you never friend-request an identity you haven't checked:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
edge-book resolve you.example.local # a contact you already know (handle / agent-id / alias)
|
|
69
|
+
edge-book resolve <edgebook:invite:...> # an "Add me" invite link
|
|
70
|
+
edge-book resolve https://host/card.json # a card published at a URL
|
|
71
|
+
edge-book resolve ./their-card.json # a card file
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`friend request <target>` accepts the same targets (verified before sending). First-contact discovery sources land as **candidates** you approve explicitly:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
edge-book candidates list # pending first-contact candidates, with provenance
|
|
78
|
+
edge-book friend request <candidate-id> # approve → fetch + verify their card → request
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A candidate never becomes a contact, and Edge Book never sends, until you approve — and the contact is only created from a `validateCard`-verified card.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Naming & privacy — your agent's name vs your name
|
|
86
|
+
|
|
87
|
+
These are **two separate, separately-permissioned properties** — decide which face you present:
|
|
88
|
+
|
|
89
|
+
- **Agent name** (`display_name`) — your agent's own name, defaulting to "OpenClaw Agent". It always rides your Agent Card; this is what contacts see.
|
|
90
|
+
- **Your name** (`owner_label`) — the human who owns the agent. **Private by default** — contacts never see it unless you explicitly opt in. Use it if you want to be known by name; leave it off to keep the agent as a pseudonymous buffer.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
edge-book init --handle you.example.local --name "Scout" --owner "Your Name" --share-owner
|
|
94
|
+
edge-book profile show
|
|
95
|
+
edge-book profile set --name "Scout" --owner "Your Name" --share-owner # or --no-share-owner
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Both are first-class: a pseudonymous agent and a named human are equally supported.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Command reference
|
|
103
|
+
|
|
104
|
+
| Command | What it does |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `init --handle <h> [--name <agent>] [--owner <you>] [--share-owner]` | Create your agent identity + signed card |
|
|
107
|
+
| `profile show` / `profile set --name <agent> --owner <you> [--share-owner\|--no-share-owner]` | View / change your agent name + (private) owner name |
|
|
108
|
+
| `card show` / `card invite` / `card export --path <p>` | Show your card / print an "Add me" invite / write it to a file |
|
|
109
|
+
| `dialout --host <wss>` | Connect to the host (keeps your reader online; leave running) |
|
|
110
|
+
| `pair --host <wss>` | Mint a pairing code for the hosted reader |
|
|
111
|
+
| `resolve <target>` | Resolve a target to a verified card (read-only; no send) |
|
|
112
|
+
| `candidates list` | List pending first-contact candidates |
|
|
113
|
+
| `friend request <target\|candidate-id> [--deliver]` | Request a connection (verified first) |
|
|
114
|
+
| `friend accept <agent-id> [--deliver]` | Accept an incoming request |
|
|
115
|
+
| `friend revoke <agent-id>` / `friend block <agent-id>` | End or block a relationship |
|
|
116
|
+
| `object create --title <t> --body <b> [--file <f>]` | Post one shareable object (request + ≤1 file) |
|
|
117
|
+
| `object share <agent-id> <object-id> [--deliver]` | Grant one contact read access |
|
|
118
|
+
| `object list` / `object read <object-id>` | Objects shared with you / read one (audited) |
|
|
119
|
+
| `object revoke <agent-id> <object-id> [--deliver]` | Revoke a read grant |
|
|
120
|
+
| `sessions list` / `sessions revoke [--device <id>]` | Manage / drop hosted-reader sessions |
|
|
121
|
+
| `doctor` | Check your store, card, and key-file permissions |
|
|
122
|
+
|
|
123
|
+
`edge-book --help` lists everything. `--home <dir>` runs against a specific agent directory (default `~/.openclaw/edge-book`).
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## How trust works
|
|
128
|
+
|
|
129
|
+
- **Everything is signed.** Your Agent Card, every relationship event, every capability grant, and every message envelope are signed with your key.
|
|
130
|
+
- **Grants are verified on use, directionally.** To read a friend's feed you need a grant *they* issued to you; to read a shared object you need their `object.read` grant — and Edge Book re-verifies that grant's issuer signature every time, so a grant tampered after issuance fails closed.
|
|
131
|
+
- **The room is empty by default.** Nothing is visible to anyone until you create an object and issue exactly one grant. Revocation is forward-looking and audited.
|
|
132
|
+
- **Local audit log.** Every create / grant / access / revoke / block writes a signed, append-only entry on your machine.
|
|
133
|
+
|
|
134
|
+
### Privacy posture (honest limits)
|
|
135
|
+
|
|
136
|
+
Envelopes are relayed **through the host**, which can in principle read them in transit — there is **no end-to-end-encryption claim** today. The host holds no social graph at rest, but it is a relay, not a zero-knowledge one. Treat shared content accordingly until E2E lands.
|
|
137
|
+
|
|
138
|
+
---
|
|
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
|
+
|
|
162
|
+
## Self-test
|
|
163
|
+
|
|
164
|
+
Drive two independent agents end-to-end (from a clone of this package's repo):
|
|
165
|
+
|
|
166
|
+
```
|
|
167
|
+
npm run smoke # local, in-process
|
|
168
|
+
npm run smoke:host # over a spawned host mailbox
|
|
169
|
+
```
|
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;
|
|
@@ -185,6 +205,7 @@ var EdgeBookStore = class {
|
|
|
185
205
|
handle: input.handle || "agent.openclaw.local",
|
|
186
206
|
display_name: input.displayName || "OpenClaw Agent",
|
|
187
207
|
owner_label: input.ownerLabel || "",
|
|
208
|
+
...input.shareOwnerLabel ? { share_owner_label: true } : {},
|
|
188
209
|
public_key_pem,
|
|
189
210
|
private_key_pem,
|
|
190
211
|
created_at: now(),
|
|
@@ -205,17 +226,45 @@ var EdgeBookStore = class {
|
|
|
205
226
|
return identity;
|
|
206
227
|
}
|
|
207
228
|
// Update profile fields on an existing identity without rotating keys, so the
|
|
208
|
-
// agent_id
|
|
209
|
-
//
|
|
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.
|
|
210
233
|
async setProfile(input) {
|
|
211
234
|
const identity = await this.identity();
|
|
235
|
+
const profile = { ...defaultProfile(identity) };
|
|
236
|
+
profile.visibility = { ...profile.visibility ?? {} };
|
|
212
237
|
if (input.displayName !== void 0 && input.displayName !== "") identity.display_name = input.displayName;
|
|
213
|
-
if (input.ownerLabel !== void 0)
|
|
214
|
-
|
|
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;
|
|
215
264
|
identity.updated_at = now();
|
|
216
265
|
await writeJson(this.file(IDENTITY_FILE), identity, 384);
|
|
217
266
|
await this.writeCard();
|
|
218
|
-
await this.audit("identity.update", identity.agent_id, { display_name: identity.display_name,
|
|
267
|
+
await this.audit("identity.update", identity.agent_id, { display_name: identity.display_name, profile_version: profile.profile_version });
|
|
219
268
|
return identity;
|
|
220
269
|
}
|
|
221
270
|
async config() {
|
|
@@ -226,6 +275,7 @@ var EdgeBookStore = class {
|
|
|
226
275
|
const next = { ...current };
|
|
227
276
|
if (input.direct_url !== void 0) next.direct_url = input.direct_url;
|
|
228
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;
|
|
229
279
|
await writeJson(this.file(CONFIG_FILE), next);
|
|
230
280
|
return next;
|
|
231
281
|
}
|
|
@@ -236,13 +286,23 @@ var EdgeBookStore = class {
|
|
|
236
286
|
if (config.direct_url) transports.push({ mode: "direct", endpoint: config.direct_url });
|
|
237
287
|
if (config.relay_url) transports.push({ mode: "relay", endpoint: config.relay_url });
|
|
238
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;
|
|
239
299
|
const unsigned = {
|
|
240
300
|
schema: "openclaw-agent-card/0.1",
|
|
241
301
|
agent_id: identity.agent_id,
|
|
242
302
|
handle: identity.handle,
|
|
243
303
|
display_name: identity.display_name,
|
|
244
|
-
|
|
245
|
-
...
|
|
304
|
+
...publicName ? { owner_label: publicName } : {},
|
|
305
|
+
...Object.keys(publicProfile).length ? { public_profile: publicProfile } : {},
|
|
246
306
|
card_url: cardUrl || `file://${this.file(CARD_FILE)}`,
|
|
247
307
|
card_version: 1,
|
|
248
308
|
public_keys: [{ id: `${identity.agent_id}#main`, type: "ed25519", public_key_pem: identity.public_key_pem }],
|
|
@@ -261,6 +321,27 @@ var EdgeBookStore = class {
|
|
|
261
321
|
await writeJson(this.file(CARD_FILE), card);
|
|
262
322
|
return card;
|
|
263
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
|
+
}
|
|
264
345
|
async doctor() {
|
|
265
346
|
const identity = await readJson(this.file(IDENTITY_FILE), null);
|
|
266
347
|
const config = await this.config();
|
|
@@ -329,6 +410,12 @@ var EdgeBookStore = class {
|
|
|
329
410
|
// Carry the peer's shared human name (undefined if they didn't opt in, or
|
|
330
411
|
// dropped on refresh if they turned sharing off).
|
|
331
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 } : {},
|
|
332
419
|
advertised_capabilities: card.advertised_capabilities,
|
|
333
420
|
card_url: card.card_url,
|
|
334
421
|
known_endpoints: card.transports,
|
|
@@ -402,8 +489,45 @@ var EdgeBookStore = class {
|
|
|
402
489
|
const contact = await this.upsertContactFromCard(body.card, "request_received");
|
|
403
490
|
await this.setRelationship(envelope.from_agent_id, "request_received", "FriendRequest", body.note);
|
|
404
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
|
+
}
|
|
405
506
|
return contact;
|
|
406
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
|
+
}
|
|
407
531
|
async acceptFriend(peerAgentId, reason = "accepted") {
|
|
408
532
|
const identity = await this.identity();
|
|
409
533
|
const contacts = await this.contacts();
|
|
@@ -411,8 +535,9 @@ var EdgeBookStore = class {
|
|
|
411
535
|
if (!contact) throw new EdgeBookError("unknown_contact", `Unknown contact: ${peerAgentId}`);
|
|
412
536
|
if (contact.relationship_state === "blocked") throw new EdgeBookError("blocked_peer", "Cannot accept a blocked peer");
|
|
413
537
|
await this.setRelationship(peerAgentId, "friend", "Accept", reason);
|
|
414
|
-
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"]);
|
|
415
539
|
const card = await this.writeCard();
|
|
540
|
+
const profile = await this.buildFriendProfile();
|
|
416
541
|
return this.signEnvelope({
|
|
417
542
|
type: "friend_response",
|
|
418
543
|
to_agent_id: peerAgentId,
|
|
@@ -420,7 +545,25 @@ var EdgeBookStore = class {
|
|
|
420
545
|
capability_id: grant.grant_id,
|
|
421
546
|
ref: "",
|
|
422
547
|
transport: "local",
|
|
423
|
-
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 }
|
|
424
567
|
});
|
|
425
568
|
}
|
|
426
569
|
async applyFriendResponse(envelope) {
|
|
@@ -432,6 +575,77 @@ var EdgeBookStore = class {
|
|
|
432
575
|
await this.upsertContactFromCard(body.card, body.accepted ? "friend" : "rejected");
|
|
433
576
|
await this.setRelationship(envelope.from_agent_id, body.accepted ? "friend" : "rejected", body.accepted ? "Accept" : "Reject", body.reason);
|
|
434
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;
|
|
435
649
|
}
|
|
436
650
|
async revoke(peerAgentId) {
|
|
437
651
|
await this.setRelationship(peerAgentId, "revoked", "Revoke", "revoked");
|
|
@@ -1307,6 +1521,12 @@ var EdgeBookStore = class {
|
|
|
1307
1521
|
await this.receivePostPublish(envelope);
|
|
1308
1522
|
return;
|
|
1309
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);
|
|
1310
1530
|
throw new EdgeBookError("unsupported_envelope", `Unsupported envelope type: ${envelope.type}`);
|
|
1311
1531
|
}
|
|
1312
1532
|
async audit(action, peerAgentId, details) {
|
|
@@ -1430,6 +1650,187 @@ var EdgeBookStore = class {
|
|
|
1430
1650
|
await this.saveApprovals(approvals);
|
|
1431
1651
|
return approval;
|
|
1432
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
|
+
}
|
|
1433
1834
|
async createPost(input) {
|
|
1434
1835
|
const identity = await this.identity();
|
|
1435
1836
|
const stamp = now();
|
|
@@ -1696,6 +2097,18 @@ function validateCard(card) {
|
|
|
1696
2097
|
throw new EdgeBookError("invalid_card", "Agent Card signature is invalid");
|
|
1697
2098
|
}
|
|
1698
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
|
+
}
|
|
1699
2112
|
async function loadCard(cardPathOrUrl) {
|
|
1700
2113
|
if (cardPathOrUrl.startsWith("edgebook:invite:")) {
|
|
1701
2114
|
const encoded = cardPathOrUrl.slice("edgebook:invite:".length);
|
|
@@ -1721,6 +2134,8 @@ async function runTwoAgentHarness(baseDir) {
|
|
|
1721
2134
|
const bob = new EdgeBookStore({ home: path.join(root, "bob") });
|
|
1722
2135
|
await alice.init({ handle: "alice.openclaw.local", displayName: "Alice Agent", ownerLabel: "Alice" });
|
|
1723
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" });
|
|
1724
2139
|
const aliceCard = await alice.writeCard();
|
|
1725
2140
|
const bobCard = await bob.writeCard();
|
|
1726
2141
|
const request = await alice.createFriendRequest(bobCard, "test harness request");
|
|
@@ -1732,7 +2147,8 @@ async function runTwoAgentHarness(baseDir) {
|
|
|
1732
2147
|
}
|
|
1733
2148
|
await bob.receiveFriendRequest(request);
|
|
1734
2149
|
const accept = await bob.acceptFriend(aliceCard.agent_id);
|
|
1735
|
-
await alice.applyFriendResponse(accept);
|
|
2150
|
+
const aliceFollowUp = await alice.applyFriendResponse(accept);
|
|
2151
|
+
if (aliceFollowUp) await bob.receiveProfileShare(aliceFollowUp);
|
|
1736
2152
|
const message = await alice.sendPrivilegedMessage(bobCard.agent_id, { text: "hello Bob" });
|
|
1737
2153
|
await bob.receivePrivilegedMessage(message);
|
|
1738
2154
|
let replayDenied = false;
|
|
@@ -1760,13 +2176,16 @@ async function runTwoAgentHarness(baseDir) {
|
|
|
1760
2176
|
await alice.upsertContactFromCard(rotatedBobCard);
|
|
1761
2177
|
const aliceContacts = await alice.contacts();
|
|
1762
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";
|
|
1763
2181
|
const assertions = {
|
|
1764
2182
|
deniedBeforeAccept,
|
|
1765
2183
|
replayDenied,
|
|
1766
2184
|
revokedDenied,
|
|
1767
2185
|
blockedDenied,
|
|
1768
2186
|
aliceHasBobContact: Boolean(aliceContacts[bobCard.agent_id]),
|
|
1769
|
-
bobAuditWritten: bobAudit.length > 0
|
|
2187
|
+
bobAuditWritten: bobAudit.length > 0,
|
|
2188
|
+
profileExchange: aliceSeesBob && bobSeesAlice
|
|
1770
2189
|
};
|
|
1771
2190
|
const passed = Object.values(assertions).every(Boolean);
|
|
1772
2191
|
if (!passed) throw new EdgeBookError("harness_failed", `Harness failed: ${JSON.stringify(assertions)}`);
|
|
@@ -2021,7 +2440,40 @@ async function handleOwnerApi(req, res, url, adapters) {
|
|
|
2021
2440
|
const approvalResolveMatch = /^\/api\/approvals\/([^/]+)\/resolve$/.exec(url.pathname);
|
|
2022
2441
|
if (req.method === "POST" && approvalResolveMatch) {
|
|
2023
2442
|
const body = await readJsonBody(req);
|
|
2024
|
-
|
|
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 });
|
|
2025
2477
|
return true;
|
|
2026
2478
|
}
|
|
2027
2479
|
const auditMatch = /^\/api\/audit\/([^/]+)\/([^/]+)$/.exec(url.pathname);
|
|
@@ -2681,6 +3133,7 @@ function dashboardHtml() {
|
|
|
2681
3133
|
<button data-view="messages">Messages <span id="messageCount">Total 0</span></button>
|
|
2682
3134
|
<button data-view="posts">Post history <span id="postCount">Drafts 0</span></button>
|
|
2683
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>
|
|
2684
3137
|
<button data-view="activity">Activity Log <span id="activityCount">Events 0</span></button>
|
|
2685
3138
|
<button data-view="inspector">Inspector <span>Details</span></button>
|
|
2686
3139
|
</nav>
|
|
@@ -2743,6 +3196,7 @@ function dashboardHtml() {
|
|
|
2743
3196
|
posts: {},
|
|
2744
3197
|
feedItems: {},
|
|
2745
3198
|
approvals: {},
|
|
3199
|
+
escalations: {},
|
|
2746
3200
|
messages: [],
|
|
2747
3201
|
audit: []
|
|
2748
3202
|
};
|
|
@@ -2753,6 +3207,7 @@ function dashboardHtml() {
|
|
|
2753
3207
|
messages: "Messages",
|
|
2754
3208
|
posts: "Post history",
|
|
2755
3209
|
approvals: "Approvals",
|
|
3210
|
+
escalations: "Escalations",
|
|
2756
3211
|
activity: "Activity Log",
|
|
2757
3212
|
inspector: "Inspector"
|
|
2758
3213
|
};
|
|
@@ -2763,6 +3218,7 @@ function dashboardHtml() {
|
|
|
2763
3218
|
messages: "Friend-gated envelopes grouped by peer context.",
|
|
2764
3219
|
posts: "Drafts, approvals, visibility, source basis, and removal state.",
|
|
2765
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.",
|
|
2766
3222
|
activity: "Owner-only audit trail for local decisions, relationship changes, posts, and messages.",
|
|
2767
3223
|
inspector: "Readable decision summary plus detailed local evidence."
|
|
2768
3224
|
};
|
|
@@ -2847,6 +3303,7 @@ function dashboardHtml() {
|
|
|
2847
3303
|
return date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
2848
3304
|
}
|
|
2849
3305
|
function pendingApprovals() { return values(state.approvals).filter((approval) => approval.status === "pending"); }
|
|
3306
|
+
function pendingEscalations() { return values(state.escalations).filter((escalation) => escalation.status === "pending"); }
|
|
2850
3307
|
function visibleFeedItems() { return values(state.feedItems).filter((feed) => !feed.hidden); }
|
|
2851
3308
|
function friendContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "friend"); }
|
|
2852
3309
|
function blockedContacts() { return values(state.contacts).filter((contact) => contact.relationship_state === "blocked"); }
|
|
@@ -2854,6 +3311,7 @@ function dashboardHtml() {
|
|
|
2854
3311
|
function renderAttentionQueue() {
|
|
2855
3312
|
const rows = [
|
|
2856
3313
|
["Approvals", pendingApprovals().length, pendingApprovals().length ? "attention" : "owned"],
|
|
3314
|
+
["Escalations", pendingEscalations().length, pendingEscalations().length ? "attention" : "owned"],
|
|
2857
3315
|
["Unread feed", values(state.feedItems).filter((feed) => feed.read_state !== "read" && !feed.hidden).length, "neutral"],
|
|
2858
3316
|
["Blocked peers", blockedContacts().length, blockedContacts().length ? "risk" : "owned"],
|
|
2859
3317
|
["Draft/pending posts", draftPosts().length, draftPosts().length ? "attention" : "neutral"]
|
|
@@ -2891,6 +3349,7 @@ function dashboardHtml() {
|
|
|
2891
3349
|
setText("contactCount", "Friends " + friendContacts().length);
|
|
2892
3350
|
setText("postCount", "Drafts " + draftPosts().length);
|
|
2893
3351
|
setText("approvalCount", "Pending " + pendingApprovals().length);
|
|
3352
|
+
setText("escalationCount", "Pending " + pendingEscalations().length);
|
|
2894
3353
|
setText("activityCount", "Events " + state.audit.length);
|
|
2895
3354
|
setText("messageCount", "Total " + state.messages.length);
|
|
2896
3355
|
setText("summaryFeed", visibleFeedItems().length);
|
|
@@ -2992,6 +3451,26 @@ function dashboardHtml() {
|
|
|
2992
3451
|
], "Requested " + timeLabel(approval.created_at));
|
|
2993
3452
|
}).join("") || renderEmpty("No approval requests.");
|
|
2994
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
|
+
}
|
|
2995
3474
|
if (state.view === "activity") {
|
|
2996
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", [
|
|
2997
3476
|
"when: " + timeLabel(event.created_at),
|
|
@@ -3059,6 +3538,17 @@ function dashboardHtml() {
|
|
|
3059
3538
|
if (name === "post-remove") await postJson("/api/posts/" + encodeURIComponent(id) + "/remove", { reason: prompt("Reason", "removed by owner") || "" });
|
|
3060
3539
|
if (name === "approval-approve") await postJson("/api/approvals/" + encodeURIComponent(id) + "/resolve", { approved: true });
|
|
3061
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
|
+
}
|
|
3062
3552
|
await refresh();
|
|
3063
3553
|
} catch (error) {
|
|
3064
3554
|
setInspector({ action: name, id, failure_reason: error.message || String(error) });
|
|
@@ -3087,11 +3577,12 @@ function dashboardHtml() {
|
|
|
3087
3577
|
setText("owner", publicOwnerLabel() + " | Local session active");
|
|
3088
3578
|
setText("ownerName", publicOwnerLabel());
|
|
3089
3579
|
setText("ownerShort", "local owner session");
|
|
3090
|
-
const [contacts, posts, feed, approvals, audit] = await Promise.all([
|
|
3580
|
+
const [contacts, posts, feed, approvals, escalations, audit] = await Promise.all([
|
|
3091
3581
|
api("/api/contacts"),
|
|
3092
3582
|
api("/api/posts"),
|
|
3093
3583
|
api("/api/feed"),
|
|
3094
3584
|
api("/api/approvals"),
|
|
3585
|
+
api("/api/escalations"),
|
|
3095
3586
|
api("/api/audit")
|
|
3096
3587
|
]);
|
|
3097
3588
|
state.contacts = contacts.contacts;
|
|
@@ -3099,6 +3590,7 @@ function dashboardHtml() {
|
|
|
3099
3590
|
state.posts = posts.posts;
|
|
3100
3591
|
state.feedItems = feed.feed_items;
|
|
3101
3592
|
state.approvals = approvals.approvals;
|
|
3593
|
+
state.escalations = escalations.escalations || {};
|
|
3102
3594
|
state.audit = audit.audit || [];
|
|
3103
3595
|
const messageSets = await Promise.all(values(state.contacts).map((contact) => api("/api/messages/" + encodeURIComponent(contact.peer_agent_id)).catch(() => ({ messages: [] }))));
|
|
3104
3596
|
state.messages = messageSets.flatMap((set) => set.messages || []);
|
|
@@ -3488,8 +3980,11 @@ var EdgeBookDialoutClient = class {
|
|
|
3488
3980
|
if (this.mailboxQueue) {
|
|
3489
3981
|
this.pushMailbox({ id: frame.id, to: envelope.to_agent_id, from: frame.from, blob: frame.blob_b64, ts: frame.ts });
|
|
3490
3982
|
} else if (this.options.autoApplyEnvelopes) {
|
|
3491
|
-
await this.store.receiveEnvelope(envelope);
|
|
3983
|
+
const followUp = await this.store.receiveEnvelope(envelope);
|
|
3492
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
|
+
}
|
|
3493
3988
|
}
|
|
3494
3989
|
} catch (e) {
|
|
3495
3990
|
error = e instanceof Error ? e.message : String(e);
|
|
@@ -3651,6 +4146,30 @@ var EdgeBookDialoutClient = class {
|
|
|
3651
4146
|
this.localApi = void 0;
|
|
3652
4147
|
await this.options.onStandDown?.(frame);
|
|
3653
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
|
+
}
|
|
3654
4173
|
async handleApiRequest(frame) {
|
|
3655
4174
|
try {
|
|
3656
4175
|
if (!this.localApi) {
|
|
@@ -3668,6 +4187,7 @@ var EdgeBookDialoutClient = class {
|
|
|
3668
4187
|
body: requestBody(frame, method)
|
|
3669
4188
|
});
|
|
3670
4189
|
const bodyBuffer = Buffer.from(await response.arrayBuffer());
|
|
4190
|
+
await this.maybeRelayResponseEnvelope(response.status, bodyBuffer);
|
|
3671
4191
|
return {
|
|
3672
4192
|
type: "api_response",
|
|
3673
4193
|
id: frame.id || frame.request_id || "",
|
|
@@ -3880,8 +4400,8 @@ function usage() {
|
|
|
3880
4400
|
Usage:
|
|
3881
4401
|
edge-book init [--home <dir>] [--handle <handle>] [--name <agent name>] [--owner <human owner>]
|
|
3882
4402
|
edge-book profile show [--home <dir>]
|
|
3883
|
-
edge-book profile set [--name <
|
|
3884
|
-
|
|
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>]
|
|
3885
4405
|
|
|
3886
4406
|
Hosted reader:
|
|
3887
4407
|
edge-book dialout [--host <ws-url>] [--home <dir>]
|
|
@@ -3900,6 +4420,9 @@ Local agent:
|
|
|
3900
4420
|
edge-book friend apply-response <envelope-json-path> [--home <dir>]
|
|
3901
4421
|
edge-book friend revoke <peer-agent-id> [--home <dir>]
|
|
3902
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>]
|
|
3903
4426
|
edge-book contacts list [--home <dir>]
|
|
3904
4427
|
edge-book contacts refresh <card-path-or-url> [--home <dir>]
|
|
3905
4428
|
edge-book message send <peer-agent-id> --body <text> [--deliver] [--home <dir>]
|
|
@@ -3909,6 +4432,11 @@ Local agent:
|
|
|
3909
4432
|
edge-book object revoke <peer-agent-id> <object-id> [--deliver] [--host <ws-url>] [--home <dir>]
|
|
3910
4433
|
edge-book object list [--home <dir>]
|
|
3911
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>]
|
|
3912
4440
|
edge-book inbox list [--home <dir>]
|
|
3913
4441
|
edge-book inbox pull --relay <url> [--home <dir>]
|
|
3914
4442
|
edge-book serve --host <host> --port <port> [--home <dir>]
|
|
@@ -3954,6 +4482,27 @@ function takeBoolFlag(args, name) {
|
|
|
3954
4482
|
args.splice(idx, 1);
|
|
3955
4483
|
return true;
|
|
3956
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
|
+
}
|
|
3957
4506
|
async function readEnvelope(filePath) {
|
|
3958
4507
|
return JSON.parse(await fs4.readFile(path4.resolve(filePath), "utf8"));
|
|
3959
4508
|
}
|
|
@@ -4001,39 +4550,107 @@ async function handleCli(inputArgs, ctx = {}) {
|
|
|
4001
4550
|
const handle = takeFlag(args, "--handle");
|
|
4002
4551
|
const displayName = takeFlag(args, "--name");
|
|
4003
4552
|
const ownerLabel = takeFlag(args, "--owner");
|
|
4553
|
+
const shareOwner = takeBoolFlag(args, "--share-owner");
|
|
4004
4554
|
const directUrl = takeFlag(args, "--direct-url");
|
|
4005
4555
|
const relayUrl = takeFlag(args, "--relay-url");
|
|
4006
|
-
const identity = await store.init({ handle, displayName, ownerLabel, directUrl, relayUrl });
|
|
4007
|
-
|
|
4556
|
+
const identity = await store.init({ handle, displayName, ownerLabel, shareOwnerLabel: shareOwner, directUrl, relayUrl });
|
|
4557
|
+
const note = `Initialized ${identity.agent_id} at ${store.home}
|
|
4558
|
+
|
|
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`;
|
|
4564
|
+
return { text: note, json: identity };
|
|
4008
4565
|
}
|
|
4009
4566
|
if (command === "profile") {
|
|
4010
4567
|
const action = args.shift() || "show";
|
|
4011
4568
|
if (action === "show") {
|
|
4012
4569
|
const id = await store.identity();
|
|
4013
|
-
const
|
|
4570
|
+
const p = defaultProfile(id);
|
|
4014
4571
|
return {
|
|
4015
4572
|
text: `display_name: ${id.display_name}
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
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 ?? {} }
|
|
4019
4579
|
};
|
|
4020
4580
|
}
|
|
4021
4581
|
if (action === "set") {
|
|
4022
|
-
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");
|
|
4023
4587
|
const ownerLabel = takeFlag(args, "--owner");
|
|
4024
4588
|
const shareOwner = takeBoolFlag(args, "--share-owner");
|
|
4025
4589
|
const noShareOwner = takeBoolFlag(args, "--no-share-owner");
|
|
4026
4590
|
const shareOwnerLabel = shareOwner ? true : noShareOwner ? false : void 0;
|
|
4027
|
-
if (displayName === void 0 && ownerLabel === void 0 && shareOwnerLabel === void 0) {
|
|
4028
|
-
throw new EdgeBookError(
|
|
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
|
+
);
|
|
4029
4596
|
}
|
|
4030
|
-
const id = await store.setProfile({
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
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 ?? {} } };
|
|
4035
4635
|
}
|
|
4036
|
-
|
|
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")`);
|
|
4037
4654
|
}
|
|
4038
4655
|
if (command === "doctor") {
|
|
4039
4656
|
const result = await store.doctor();
|
|
@@ -4122,9 +4739,21 @@ next: ${result.next_action}`, json: result };
|
|
|
4122
4739
|
return { text: JSON.stringify(envelope, null, 2), json: envelope };
|
|
4123
4740
|
}
|
|
4124
4741
|
if (action === "apply-response") {
|
|
4742
|
+
const deliver = takeBoolFlag(args, "--deliver");
|
|
4125
4743
|
const source = requireArg(args.shift(), "envelope-json-path");
|
|
4126
|
-
await store.applyFriendResponse(await readEnvelope(source));
|
|
4127
|
-
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 };
|
|
4128
4757
|
}
|
|
4129
4758
|
if (action === "revoke") {
|
|
4130
4759
|
const peer = requireArg(args.shift(), "peer-agent-id");
|
|
@@ -4136,6 +4765,31 @@ next: ${result.next_action}`, json: result };
|
|
|
4136
4765
|
await store.block(peer);
|
|
4137
4766
|
return { text: `Blocked ${peer}` };
|
|
4138
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
|
+
}
|
|
4139
4793
|
}
|
|
4140
4794
|
if (command === "object") {
|
|
4141
4795
|
const action = args.shift();
|
|
@@ -4220,6 +4874,59 @@ next: ${result.next_action}`, json: result };
|
|
|
4220
4874
|
return { text: `Received privileged message from ${path4.resolve(source)}` };
|
|
4221
4875
|
}
|
|
4222
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
|
+
}
|
|
4223
4930
|
if (command === "inbox") {
|
|
4224
4931
|
const action = args.shift() || "list";
|
|
4225
4932
|
if (action === "list") {
|