@decentnetwork/beagle 0.1.111 → 0.1.112

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/dist/cli.js CHANGED
@@ -172,6 +172,7 @@ async function main() {
172
172
  doraRosterPath,
173
173
  downloadsDir: resolve(args.configDir, "downloads"),
174
174
  messagesPath: resolve(args.configDir, "messages.json"),
175
+ servicePeersPath: resolve(args.configDir, "service-peers-local.json"),
175
176
  callIce: {
176
177
  keyFile: resolve(decentlanCarrierDir(args.configDir), "keypair.json"),
177
178
  bootstrapNodes,
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.111";
1
+ window.__DK_UI_VERSION="0.1.112";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -644,6 +644,18 @@ window.__DK_UI_VERSION="0.1.111";
644
644
  confirm: "list",
645
645
  scope: "assistant"
646
646
  },
647
+ {
648
+ name: "nodes-refresh",
649
+ usage: "/nodes refresh",
650
+ summary: "Look for friends that are provably service peers (the shipped exit table, this machine's dora roster) and offer to reclassify them. Nothing changes until you tick and confirm.",
651
+ params: {
652
+ type: "object",
653
+ properties: {},
654
+ required: []
655
+ },
656
+ confirm: "list",
657
+ scope: "assistant"
658
+ },
647
659
  {
648
660
  name: "find",
649
661
  usage: "/find <text>",
@@ -818,7 +830,8 @@ window.__DK_UI_VERSION="0.1.111";
818
830
  file: "service-peers.json",
819
831
  loader: "loadServicePeers()",
820
832
  rule: "A peer is a service peer if and only if its userid is in this list. The Assistant never infers it from a name or from having no messages. /friends excludes them entirely; /nodes is only them.",
821
- later: "A dora / ENS / bridge / directory service returns the same shape; the bundled file is v1."
833
+ later: "A dora / ENS / bridge / directory service returns the same shape; the bundled file is v1.",
834
+ refresh: "/nodes refresh asks GET /api/assistant/service-candidates for evidence-backed candidates and writes the ticked ones to this machine's overlay (POST /api/assistant/service-peers, stored at <configDir>/service-peers-local.json). The shipped list is never edited at runtime; the overlay wins on the same userid."
822
835
  }
823
836
  };
824
837
  var service_peers_default = {
@@ -973,6 +986,8 @@ window.__DK_UI_VERSION="0.1.111";
973
986
  const sub = (parts[1] || "").toLowerCase();
974
987
  if (sub === "list" || sub === "remove")
975
988
  return { ok: true, name: `${head}-${sub}`, args: parts.slice(2) };
989
+ if (head === "nodes" && sub === "refresh")
990
+ return { ok: true, name: "nodes-refresh", args: parts.slice(2) };
976
991
  return { ok: false, name: head, args: parts.slice(1), error: `${head}-subcommand` };
977
992
  }
978
993
  return { ok: true, name: head, args: parts.slice(1) };
@@ -1010,6 +1025,19 @@ window.__DK_UI_VERSION="0.1.111";
1010
1025
  }
1011
1026
  return map;
1012
1027
  }
1028
+ function mergeServicePeers(bundled, local) {
1029
+ const byId = /* @__PURE__ */ new Map();
1030
+ for (const p of bundled && bundled.peers || [])
1031
+ if (p && p.userid)
1032
+ byId.set(p.userid, { ...p, source: "app" });
1033
+ for (const p of local && local.peers || [])
1034
+ if (p && p.userid)
1035
+ byId.set(p.userid, { ...p, source: "local" });
1036
+ return { version: 1, source: "merged", peers: [...byId.values()] };
1037
+ }
1038
+ function newCandidates(candidates, svcMap) {
1039
+ return (candidates != null ? candidates : []).filter((c) => c && c.userid && !(svcMap && svcMap.has(c.userid)));
1040
+ }
1013
1041
  function partitionPeers(friends, svcMap) {
1014
1042
  const people = [];
1015
1043
  const nodes = [];
@@ -1092,8 +1120,13 @@ window.__DK_UI_VERSION="0.1.111";
1092
1120
  return { match: partial[0], candidates: partial };
1093
1121
  return { match: null, candidates: partial };
1094
1122
  }
1095
- function loadServicePeers() {
1096
- return service_peers_default;
1123
+ async function loadServicePeers() {
1124
+ try {
1125
+ const local = await fetch("/api/assistant/service-peers").then((r) => r.json());
1126
+ return mergeServicePeers(service_peers_default, local);
1127
+ } catch (e) {
1128
+ return service_peers_default;
1129
+ }
1097
1130
  }
1098
1131
  var ASSISTANT_PEER = {
1099
1132
  id: ASSISTANT_ID,
@@ -1149,10 +1182,12 @@ window.__DK_UI_VERSION="0.1.111";
1149
1182
  const tail = lang === "zh" ? `\u7B5B\u9009\u6761\u4EF6\uFF08\u53EF\u7EC4\u5408\uFF0C\u53D6\u5E76\u96C6\uFF09\uFF1A
1150
1183
  ${filters}
1151
1184
 
1152
- \u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u4E0D\u4F1A\u88AB"\u4ECE\u672A/\u6C89\u9ED8"\u7C7B\u7B5B\u9009\u9009\u4E2D\u3002\u5220\u9664\u6C38\u8FDC\u9700\u8981\u5148\u786E\u8BA4\u3002` : `Filters (combine them; a friend matching any one is listed):
1185
+ \`/rename\`\u3001\`/mute\`\u3001\`/find\` \u5BF9\u597D\u53CB\u548C\u670D\u52A1\u8282\u70B9\u90FD\u9002\u7528\uFF1B\u53EA\u6709\u5217\u8868\u548C\u5220\u9664\u5206\u6210\u4E24\u8FB9\u3002
1186
+ \u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u4E0D\u4F1A\u88AB"\u4ECE\u672A/\u6C89\u9ED8"\u7C7B\u7B5B\u9009\u9009\u4E2D\uFF0C\u5E76\u4F1A\u5217\u51FA\u88AB\u8DF3\u8FC7\u7684\u540D\u5B57\u3002\u5220\u9664\u6C38\u8FDC\u9700\u8981\u5148\u786E\u8BA4\uFF0C\u53D6\u6D88\u52FE\u9009\u5373\u53EF\u4FDD\u7559\u3002` : `Filters (combine them; a friend matching any one is listed):
1153
1187
  ${filters}
1154
1188
 
1155
- Friends added in the last ${RECENT_DAYS} days are never matched by the "never/silent" filters. Removal always asks first.`;
1189
+ \`/rename\`, \`/mute\` and \`/find\` work on friends and service peers alike \u2014 only listing and removing are split in two.
1190
+ Friends added in the last ${RECENT_DAYS} days are never matched by the "never/silent" filters, and the ones held back are named. Removal always asks first: untick anyone you want to keep.`;
1156
1191
  return `${intro}
1157
1192
  ${cmds}
1158
1193
 
@@ -1166,8 +1201,9 @@ ${tail}`;
1166
1201
  time: (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
1167
1202
  text
1168
1203
  }, extra);
1169
- function AssistantPanel({ m, T, lang, onRemove }) {
1204
+ function AssistantPanel({ m, T, lang, onRemove, onAdopt }) {
1170
1205
  const rows = m.assistant.rows || [];
1206
+ const adopt = m.assistant.kind === "adopt";
1171
1207
  const [sel, setSel] = React.useState(() => new Set(rows.map((r) => r.userid)));
1172
1208
  const [busy, setBusy] = React.useState(false);
1173
1209
  const [done, setDone] = React.useState(null);
@@ -1178,7 +1214,7 @@ ${tail}`;
1178
1214
  n.has(id) ? n.delete(id) : n.add(id);
1179
1215
  return n;
1180
1216
  });
1181
- const label = lang === "zh" ? `\u5220\u9664 ${sel.size} \u4F4D` : `Remove ${sel.size}`;
1217
+ const label = adopt ? lang === "zh" ? `\u52A0\u5165 ${sel.size} \u4E2A` : `Add ${sel.size} as ${sel.size === 1 ? "a node" : "nodes"}` : lang === "zh" ? `\u5220\u9664 ${sel.size} \u4F4D` : `Remove ${sel.size}`;
1182
1218
  return /* @__PURE__ */ React.createElement("div", { style: { marginTop: 8, border: "1px solid var(--line)", borderRadius: 10, overflow: "hidden" } }, rows.map((r) => /* @__PURE__ */ React.createElement("label", { key: r.userid, style: {
1183
1219
  display: "flex",
1184
1220
  alignItems: "center",
@@ -1195,12 +1231,14 @@ ${tail}`;
1195
1231
  disabled: !!done || busy,
1196
1232
  onChange: () => toggle(r.userid)
1197
1233
  }
1198
- ), /* @__PURE__ */ React.createElement("span", { style: { flex: 1 } }, nameOf(r)), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, r.userid.slice(0, 8), "\u2026"))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "8px 10px" } }, done ? /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12.5, color: "var(--faint)" } }, done) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !sel.size, onClick: () => {
1234
+ ), /* @__PURE__ */ React.createElement("span", { style: { flex: 1 } }, nameOf(r), r.why && /* @__PURE__ */ React.createElement("span", { style: { color: "var(--faint)", fontSize: 11.5 } }, ` \u2014 ${r.why}`)), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 11, color: "var(--faint)" } }, r.userid.slice(0, 8), "\u2026"))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 10, padding: "8px 10px" } }, done ? /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12.5, color: "var(--faint)" } }, done) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: adopt ? "solid" : "danger", disabled: busy || !sel.size, onClick: () => {
1199
1235
  setBusy(true);
1200
- onRemove([...sel]).then((r) => {
1201
- setDone(lang === "zh" ? `\u5DF2\u5220\u9664 ${r.removed} \u4F4D${r.failed ? `\uFF0C${r.failed} \u4F4D\u5931\u8D25` : ""}` : `Removed ${r.removed}${r.failed ? `, ${r.failed} failed` : ""}`);
1236
+ const picked = rows.filter((r) => sel.has(r.userid));
1237
+ const run = adopt ? onAdopt(picked) : onRemove([...sel]);
1238
+ run.then((r) => {
1239
+ setDone(adopt ? lang === "zh" ? `\u5DF2\u52A0\u5165 ${r.added} \u4E2A\u670D\u52A1\u8282\u70B9` : `Added ${r.added} as service peers` : lang === "zh" ? `\u5DF2\u5220\u9664 ${r.removed} \u4F4D${r.failed ? `\uFF0C${r.failed} \u4F4D\u5931\u8D25` : ""}` : `Removed ${r.removed}${r.failed ? `, ${r.failed} failed` : ""}`);
1202
1240
  }).finally(() => setBusy(false));
1203
- } }, busy ? T.working || "Working\u2026" : label), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12, color: "var(--faint)" } }, lang === "zh" ? "\u53D6\u6D88\u52FE\u9009\u53EF\u4FDD\u7559" : "Untick anyone you want to keep"))));
1241
+ } }, busy ? T.working || "Working\u2026" : label), /* @__PURE__ */ React.createElement("span", { style: { fontSize: 12, color: "var(--faint)" } }, adopt ? lang === "zh" ? "\u53D6\u6D88\u52FE\u9009\u5373\u4FDD\u6301\u4E3A\u597D\u53CB" : "Untick any you want to keep as a friend" : lang === "zh" ? "\u53D6\u6D88\u52FE\u9009\u53EF\u4FDD\u7559" : "Untick anyone you want to keep"))));
1204
1242
  }
1205
1243
  function useAssistant({ lang, T, muted, onToggleMute, sound, flash, setAlert, onReloadFriends }) {
1206
1244
  const [thread, setThread] = React.useState(() => [{ day: (/* @__PURE__ */ new Date()).toLocaleDateString() }, asMsg("them", helpText(lang))]);
@@ -1210,11 +1248,13 @@ ${tail}`;
1210
1248
  }, [lang]);
1211
1249
  const fetchFriends = async () => {
1212
1250
  const r = await fetch("/api/assistant/friends").then((x) => x.json());
1213
- const { people, nodes } = partitionPeers(r.friends || [], servicePeerMap(loadServicePeers()));
1251
+ const svcMap = servicePeerMap(await loadServicePeers());
1252
+ const { people, nodes } = partitionPeers(r.friends || [], svcMap);
1214
1253
  return {
1215
1254
  friends: people,
1216
1255
  nodes,
1217
1256
  all: r.friends || [],
1257
+ svcMap,
1218
1258
  historySince: r.historySince || null,
1219
1259
  now: r.now || Date.now(),
1220
1260
  ver: r.beagleVer || ""
@@ -1281,6 +1321,33 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1281
1321
  - Waiting to be accepted: ${pending}
1282
1322
  - History on this device since: ${fmtDate(d.historySince, lang)}
1283
1323
  - Version: ${d.ver}`));
1324
+ }
1325
+ case "nodes-refresh": {
1326
+ const d = await fetchFriends();
1327
+ const r = await fetch("/api/assistant/service-candidates").then((x) => x.json());
1328
+ const fresh = newCandidates(r.candidates || [], d.svcMap);
1329
+ const src = r.sources || {};
1330
+ const where = lang === "zh" ? `\u67E5\u8BE2\u6765\u6E90\uFF1A\u5185\u7F6E\u51FA\u53E3\u8868\uFF08${src.exits || 0} \u6761\uFF09${src.dora == null ? "\uFF0C\u672C\u673A\u4E0D\u662F dora" : `\uFF0C\u672C\u673A dora \u540D\u518C\uFF08${src.dora} \u6761\uFF09`}` : `Looked at: the shipped exit table (${src.exits || 0})${src.dora == null ? ", and this machine is not a dora" : `, and this machine's dora roster (${src.dora})`}`;
1331
+ if (!fresh.length) {
1332
+ return append(asMsg("them", lang === "zh" ? `**\u6CA1\u6709\u65B0\u7684\u670D\u52A1\u8282\u70B9**
1333
+
1334
+ \u73B0\u6709 ${d.nodes.length} \u4E2A\u5DF2\u8BC6\u522B\u3002${where}\u3002
1335
+
1336
+ *\u53EA\u91C7\u7528\u8BC1\u636E\uFF0C\u4E0D\u9760\u540D\u5B57\u731C\u6D4B\u3002*` : `**Nothing new**
1337
+
1338
+ ${d.nodes.length} service peers are already classified. ${where}.
1339
+
1340
+ *Evidence only \u2014 names are never guessed from.*`));
1341
+ }
1342
+ const title = lang === "zh" ? `**\u53D1\u73B0 ${fresh.length} \u4E2A\u53EF\u4EE5\u6807\u4E3A\u670D\u52A1\u8282\u70B9\u7684\u597D\u53CB** \u2014 \u52FE\u9009\u540E\u624D\u4F1A\u751F\u6548
1343
+
1344
+ ${where}\u3002` : `**${fresh.length === 1 ? "1 friend looks" : `${fresh.length} friends look`} like service peers** \u2014 nothing changes until you tick and confirm
1345
+
1346
+ ${where}.`;
1347
+ const lines = fresh.map((c) => `- **${c.name}** \`${c.kind}${c.role ? " \xB7 " + c.role : ""}\` \u2014 ${c.why}`).join("\n");
1348
+ return append(asMsg("them", `${title}
1349
+
1350
+ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
1284
1351
  }
1285
1352
  case "nodes-list":
1286
1353
  case "friends-list": {
@@ -1370,6 +1437,24 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1370
1437
  return append(asMsg("them", lang === "zh" ? `\u51FA\u9519\u4E86\uFF1A${e && e.message || e}` : `That failed: ${e && e.message || e}`));
1371
1438
  }
1372
1439
  }, [append, lang, muted, onToggleMute, setAlert, onReloadFriends]);
1440
+ const adoptServicePeers = React.useCallback(async (rows) => {
1441
+ const add = rows.map((r) => ({ userid: r.userid, name: r.name, kind: r.kind, role: r.role }));
1442
+ let added = 0;
1443
+ try {
1444
+ const r = await fetch("/api/assistant/service-peers", {
1445
+ method: "POST",
1446
+ headers: { "content-type": "application/json" },
1447
+ body: JSON.stringify({ add })
1448
+ }).then((x) => x.json());
1449
+ added = r && r.ok ? add.length : 0;
1450
+ } catch (e) {
1451
+ added = 0;
1452
+ }
1453
+ if (onReloadFriends)
1454
+ onReloadFriends();
1455
+ append(asMsg("them", added ? lang === "zh" ? `\u5DF2\u628A ${added} \u4F4D\u6807\u4E3A\u670D\u52A1\u8282\u70B9\uFF0C\u5B83\u4EEC\u4E0D\u518D\u51FA\u73B0\u5728 \`/friends\` \u4E2D\u3002` : `${added} now count as service peers \u2014 they no longer appear in \`/friends\`.` : lang === "zh" ? "\u5199\u5165\u5931\u8D25\uFF0C\u6CA1\u6709\u4EFB\u4F55\u6539\u52A8\u3002" : "That write failed; nothing changed."));
1456
+ return { added };
1457
+ }, [append, lang, onReloadFriends]);
1373
1458
  const removeFriends = React.useCallback(async (ids) => {
1374
1459
  let removed = 0;
1375
1460
  const failed = [];
@@ -1389,7 +1474,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1389
1474
  append(asMsg("them", failed.length ? lang === "zh" ? `\u5DF2\u5220\u9664 ${removed} \u4F4D\uFF0C${failed.length} \u4F4D\u5931\u8D25\u3002` : `Removed ${removed}, ${failed.length} failed.` : lang === "zh" ? `\u5DF2\u5220\u9664 ${removed} \u4F4D\u597D\u53CB\u3002` : `Removed ${removed} friend${removed === 1 ? "" : "s"}.`));
1390
1475
  return { removed, failed: failed.length };
1391
1476
  }, [append, lang, onReloadFriends]);
1392
- return { thread, handle, removeFriends };
1477
+ return { thread, handle, removeFriends, adoptServicePeers };
1393
1478
  }
1394
1479
  var DK_CMD_HIST_KEY = "beagle.assistant.history";
1395
1480
  var DK_CMD_HIST_MAX = 50;
@@ -2196,7 +2281,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2196
2281
  return null;
2197
2282
  }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
2198
2283
  }
2199
- function Msg({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, answered, selMode, selected, onToggleSel, busy, isGroup }) {
2284
+ function Msg({ m, peer, T, lang, onTheater, onDelete, onCancel, onRetry, onRetryMsg, onReveal, onOpenFile, onCall, onFormSubmit, onNameCard, onAssistantRemove, onAssistantAdopt, answered, selMode, selected, onToggleSel, busy, isGroup }) {
2200
2285
  const mine = m.from === "me";
2201
2286
  const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
2202
2287
  const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
@@ -2525,7 +2610,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2525
2610
  letterSpacing: -0.1,
2526
2611
  wordBreak: "break-word"
2527
2612
  } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text, onNameCard })));
2528
- })(), read.form && /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6 } }, /* @__PURE__ */ React.createElement(DkChatForm, { form: read.form, submitted, peer, onSubmit: onFormSubmit })), m.assistant && onAssistantRemove && /* @__PURE__ */ React.createElement(AssistantPanel, { m, T, lang, onRemove: onAssistantRemove }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), (() => {
2613
+ })(), read.form && /* @__PURE__ */ React.createElement("div", { style: { marginTop: 6 } }, /* @__PURE__ */ React.createElement(DkChatForm, { form: read.form, submitted, peer, onSubmit: onFormSubmit })), m.assistant && onAssistantRemove && /* @__PURE__ */ React.createElement(AssistantPanel, { m, T, lang, onRemove: onAssistantRemove, onAdopt: onAssistantAdopt }), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", alignItems: "center", gap: 4, margin: "3px 3px 0" } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10, color: "var(--faint)" } }, m.time), (() => {
2529
2614
  const sentDone = mine && m.status && m.status !== "queued" && m.status !== "sending" && m.status !== "failed";
2530
2615
  const via = !mine ? m.via : sentDone ? m.via === "offline" ? "offline" : "online" : null;
2531
2616
  if (!via)
@@ -2570,7 +2655,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2570
2655
  ) : mine && m.via === "offline" && m.status !== "queued" && m.status !== "sending" ? /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 2.2, color: "#f59e0b", title: T.viaOutOffline || "posted to the offline relay \u2014 they get it when they come online" }) : mine && m.status === "sent" && m.confirmed === false ? /* @__PURE__ */ React.createElement(Icon, { name: "check", size: 12, stroke: 2.2, color: "var(--faint)", title: T.sentUnconfirmed || "sent \u2014 not confirmed by the peer" }) : mine && m.status && /* @__PURE__ */ React.createElement(Icon, { name: "checkCheck", size: 12, stroke: 2.2, color: m.status === "read" ? "var(--accent)" : "var(--faint)" })))
2571
2656
  );
2572
2657
  }
2573
- function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted, onToggleMute, phone, onBack }) {
2658
+ function Conversation({ T, peer, lang, peers, onOpenChat, thread: threadProp, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted, onToggleMute, phone, onBack }) {
2574
2659
  const scrollRef = React.useRef(null);
2575
2660
  const prependRef = React.useRef(null);
2576
2661
  const [earlierBusy, setEarlierBusy] = React.useState(false);
@@ -3003,6 +3088,7 @@ ${peer.address}`
3003
3088
  isGroup,
3004
3089
  onTheater: setTheater,
3005
3090
  onAssistantRemove,
3091
+ onAssistantAdopt,
3006
3092
  onDelete: (id) => doDelete([id]),
3007
3093
  onCancel: doCancel,
3008
3094
  onRetry: doRetry,
@@ -3162,12 +3248,12 @@ ${peer.address}`
3162
3248
  function ChatEmpty({ T }) {
3163
3249
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 12, color: "var(--faint)", background: "var(--bg)" } }, /* @__PURE__ */ React.createElement(Icon, { name: "message", size: 40, stroke: 1.4, color: "var(--line)" }), /* @__PURE__ */ React.createElement("div", { style: { fontFamily: "var(--mono)", fontSize: 13 } }, T.pickPeer));
3164
3250
  }
3165
- function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, prefillAddr, onPrefillConsumed, muted, onToggleMute, phone, onBack }) {
3251
+ function ChatTab({ T, lang, peers, requests, activeId, thread, onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, prefillAddr, onPrefillConsumed, muted, onToggleMute, phone, onBack }) {
3166
3252
  const peer = peers.find((p) => p.id === activeId);
3167
3253
  if (phone) {
3168
- return peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted: !!muted && muted.includes(peer.id), onToggleMute, phone: true, onBack }) : /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted, wide: true });
3254
+ return peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted: !!muted && muted.includes(peer.id), onToggleMute, phone: true, onBack }) : /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted, wide: true });
3169
3255
  }
3170
- return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, muted: !!muted && muted.includes(peer.id), onToggleMute }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
3256
+ return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted: !!muted && muted.includes(peer.id), onToggleMute }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
3171
3257
  }
3172
3258
  Object.assign(window, { ChatTab });
3173
3259
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
@@ -6764,7 +6850,7 @@ ${peer.address}`
6764
6850
  fontWeight: 600,
6765
6851
  background: tab === n.id ? "var(--accent)" : "transparent",
6766
6852
  color: tab === n.id ? "#fff" : "var(--dim)"
6767
- } }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), tab === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit, t, setTweak, onSetAvatar: (p) => dkApi.setProfileFull(p).then((r) => {
6853
+ } }, /* @__PURE__ */ React.createElement(Icon, { name: n.icon, size: 14, stroke: 2, color: tab === n.id ? "#fff" : "var(--dim)" }), n.label))), tab === "chat" && /* @__PURE__ */ React.createElement(ChatTab, { T, lang: t.lang, phone, onBack: closeConversation, peers, requests, activeId, thread: activeId === ASSISTANT_ID ? assistant.thread : data.threads[activeId], onSelect, onAct, onAdd, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread: () => activeId && data.loadThread(activeId), hasEarlier: !!(activeId && activeId !== ASSISTANT_ID && data.earlier[activeId]), onAssistantRemove: activeId === ASSISTANT_ID ? assistant.removeFriends : null, onAssistantAdopt: activeId === ASSISTANT_ID ? assistant.adoptServicePeers : null, onLoadEarlier: () => activeId && data.loadEarlier(activeId), prefillAddr: pendingAddr, onPrefillConsumed: () => setPendingAddr(""), muted: mutedIds, onToggleMute }), tab === "here" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "bridge", peers, meId: me.userId, onAdd, onOpenChat }), tab === "recommended" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "recommended", peers, meId: me.userId, onAdd, onOpenChat }), tab === "registered" && /* @__PURE__ */ React.createElement(DiscoverTab, { T, kind: "registered", peers, meId: me.userId, onAdd, onOpenChat }), tab === "network" && /* @__PURE__ */ React.createElement(NetworkTab, { T, me, peers, exits, activeExit, reqCount: requests.length, onSetExit, onOpenChat, backend, onArmLan: armLan, onCancelLan: cancelLan }), tab === "apps" && /* @__PURE__ */ React.createElement(AppsTab, { T, t }), tab === "profile" && /* @__PURE__ */ React.createElement(ProfileTab, { T, me, onEdit, t, setTweak, onSetAvatar: (p) => dkApi.setProfileFull(p).then((r) => {
6768
6854
  data.refresh();
6769
6855
  return r;
6770
6856
  }) })), phone && !inConversation && /* @__PURE__ */ React.createElement("div", { style: { flexShrink: 0, display: "flex", borderTop: "1px solid var(--line)", background: "var(--rail)", paddingBottom: "env(safe-area-inset-bottom, 0px)" } }, phoneNav.map((n) => /* @__PURE__ */ React.createElement(TabBarBtn, { key: n.id, icon: n.icon, label: n.label, active: n.active, badge: n.badge, onClick: n.go }))), data.locked && /* @__PURE__ */ React.createElement(
package/dist/server.d.ts CHANGED
@@ -28,6 +28,10 @@ export interface BeagleServerOptions {
28
28
  * (never written) by GET /api/assistant/friends to count a friend's
29
29
  * history without hauling it over IPC. Undefined = no counts. */
30
30
  messagesPath?: string;
31
+ /** <configDir>/service-peers-local.json — peers THIS machine has adopted as
32
+ * service peers on top of the list shipped in the app. Written only by the
33
+ * Assistant's `/nodes refresh`, and only after the user ticks the rows. */
34
+ servicePeersPath?: string;
31
35
  /** The swappable backend, so the UI can show which one is live and drive the
32
36
  * LAN handoff. Optional: a caller that pins one backend just omits it and
33
37
  * the network panel falls back to "no virtual LAN here". */
package/dist/server.js CHANGED
@@ -121,6 +121,21 @@ function friendStoreStamps(keyFile) {
121
121
  return friendStampsCache?.map ?? empty;
122
122
  }
123
123
  }
124
+ /** The machine-local service-peer overlay. Absent file = nothing adopted yet,
125
+ * which is the normal state, not an error. */
126
+ function readLocalServicePeers(path) {
127
+ const empty = { version: 1, source: "local", peers: [] };
128
+ if (!path || !existsSync(path))
129
+ return empty;
130
+ try {
131
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
132
+ const peers = Array.isArray(raw?.peers) ? raw.peers.filter((p) => p && typeof p.userid === "string") : [];
133
+ return { version: 1, source: "local", updated: raw?.updated, peers };
134
+ }
135
+ catch {
136
+ return empty;
137
+ }
138
+ }
124
139
  /** The record beagle writes for its own name: what the profile page shows
125
140
  * (displayName, description) on top of whatever the record already held —
126
141
  * wallet binds, socials, avatar, referee — so a profile edit never strips a
@@ -2238,6 +2253,95 @@ export function startBeagleServer(opts) {
2238
2253
  sendJson(res, 200, { me, peers, requests, exits, activeExit });
2239
2254
  return;
2240
2255
  }
2256
+ // The locally adopted service peers: the app ships a list, and this is
2257
+ // what this machine added to it. Kept server-side rather than in a tab's
2258
+ // localStorage so every browser on this machine agrees about what is a
2259
+ // node — the classification decides what `/friends remove` can touch.
2260
+ if (req.method === "GET" && url === "/api/assistant/service-peers") {
2261
+ sendJson(res, 200, readLocalServicePeers(opts.servicePeersPath));
2262
+ return;
2263
+ }
2264
+ if (req.method === "POST" && url === "/api/assistant/service-peers") {
2265
+ const body = (await readBody(req));
2266
+ if (!opts.servicePeersPath)
2267
+ return sendJson(res, 400, { ok: false, error: "no config dir" });
2268
+ const current = readLocalServicePeers(opts.servicePeersPath);
2269
+ const byId = new Map(current.peers.map((p) => [p.userid, p]));
2270
+ for (const raw of Array.isArray(body.add) ? body.add : []) {
2271
+ const p = raw;
2272
+ const userid = typeof p?.userid === "string" ? p.userid : "";
2273
+ if (!userid)
2274
+ continue;
2275
+ byId.set(userid, {
2276
+ userid,
2277
+ name: String(p.name ?? userid).slice(0, 120),
2278
+ kind: p.kind === "agent" ? "agent" : "node",
2279
+ role: String(p.role ?? "").slice(0, 120),
2280
+ });
2281
+ }
2282
+ for (const raw of Array.isArray(body.remove) ? body.remove : []) {
2283
+ if (typeof raw === "string")
2284
+ byId.delete(raw);
2285
+ }
2286
+ const next = { version: 1, source: "local", updated: new Date().toISOString().slice(0, 10), peers: [...byId.values()] };
2287
+ try {
2288
+ writeFileSync(opts.servicePeersPath, JSON.stringify(next, null, 2) + "\n");
2289
+ }
2290
+ catch (e) {
2291
+ return sendJson(res, 500, { ok: false, error: e.message });
2292
+ }
2293
+ sendJson(res, 200, { ok: true, ...next });
2294
+ return;
2295
+ }
2296
+ // `/nodes refresh`: which friends does this device have EVIDENCE for?
2297
+ // Evidence only — the shipped exit table and, on a dora, its roster.
2298
+ // Nothing here reads a name or guesses from silence, and nothing is
2299
+ // adopted without the user ticking it.
2300
+ if (req.method === "GET" && url === "/api/assistant/service-candidates") {
2301
+ const flist = await opts.call({ op: "friends-list" });
2302
+ const fl = flist.ok ? (flist.data?.friends ?? []) : [];
2303
+ const evidence = new Map();
2304
+ for (const e of DEFAULT_EXITS) {
2305
+ // Role, not region: the exit table's regions carry place names this
2306
+ // app does not put in front of users (see CLAUDE.md), and "which
2307
+ // exit" is not what this answer is for.
2308
+ if (e.userid)
2309
+ evidence.set(e.userid, { kind: "node", role: "exit", why: "in the shipped exit table" });
2310
+ }
2311
+ let doraCount = 0;
2312
+ if (opts.doraRosterPath && existsSync(opts.doraRosterPath)) {
2313
+ try {
2314
+ const r = yaml.load(readFileSync(opts.doraRosterPath, "utf-8"));
2315
+ for (const rec of r?.records ?? []) {
2316
+ const uid = rec.userid || rec.pubkey;
2317
+ if (!uid)
2318
+ continue;
2319
+ doraCount++;
2320
+ evidence.set(uid, { kind: "node", role: rec.ip ? `dora member (${rec.ip})` : "dora member", why: "allocated in this machine's dora roster" });
2321
+ }
2322
+ }
2323
+ catch { /* a broken roster is not an error here */ }
2324
+ }
2325
+ const candidates = fl
2326
+ .map((f) => ({ f, ev: evidence.get(f.userid ?? "") }))
2327
+ .filter((x) => x.ev)
2328
+ .map(({ f, ev }) => ({
2329
+ userid: f.userid,
2330
+ name: f.alias || (typeof f.name === "string" ? f.name : "") || f.userid,
2331
+ kind: ev.kind,
2332
+ role: ev.role,
2333
+ why: ev.why,
2334
+ }));
2335
+ sendJson(res, 200, {
2336
+ ok: true,
2337
+ candidates,
2338
+ sources: {
2339
+ exits: DEFAULT_EXITS.length,
2340
+ dora: opts.doraRosterPath ? doraCount : null,
2341
+ },
2342
+ });
2343
+ return;
2344
+ }
2241
2345
  // Everything the Assistant's friend filters need, in one answer: the
2242
2346
  // friend list plus how much was said to each friend and when. The
2243
2347
  // counts come from the store file in our own config dir — see
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  "yargs": "^17.7.2"
36
36
  },
37
37
  "devDependencies": {
38
- "@decentnetwork/beagle-ui": "^0.2.25",
38
+ "@decentnetwork/beagle-ui": "^0.2.26",
39
39
  "@types/js-yaml": "^4.0.9",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/yargs": "^17.0.32",