@decentnetwork/beagle 0.1.110 → 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.110";
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.110";
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.110";
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.110";
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.110";
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.110";
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,15 +1182,18 @@ window.__DK_UI_VERSION="0.1.110";
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
 
1159
1194
  ${tail}`;
1160
1195
  }
1196
+ var SKIP_NAMES_MAX = 10;
1161
1197
  var seq = 0;
1162
1198
  var asMsg = (from, text, extra) => Object.assign({
1163
1199
  id: `as-${Date.now()}-${seq++}`,
@@ -1165,8 +1201,9 @@ ${tail}`;
1165
1201
  time: (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
1166
1202
  text
1167
1203
  }, extra);
1168
- function AssistantPanel({ m, T, lang, onRemove }) {
1204
+ function AssistantPanel({ m, T, lang, onRemove, onAdopt }) {
1169
1205
  const rows = m.assistant.rows || [];
1206
+ const adopt = m.assistant.kind === "adopt";
1170
1207
  const [sel, setSel] = React.useState(() => new Set(rows.map((r) => r.userid)));
1171
1208
  const [busy, setBusy] = React.useState(false);
1172
1209
  const [done, setDone] = React.useState(null);
@@ -1177,7 +1214,7 @@ ${tail}`;
1177
1214
  n.has(id) ? n.delete(id) : n.add(id);
1178
1215
  return n;
1179
1216
  });
1180
- 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}`;
1181
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: {
1182
1219
  display: "flex",
1183
1220
  alignItems: "center",
@@ -1194,12 +1231,14 @@ ${tail}`;
1194
1231
  disabled: !!done || busy,
1195
1232
  onChange: () => toggle(r.userid)
1196
1233
  }
1197
- ), /* @__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: () => {
1198
1235
  setBusy(true);
1199
- onRemove([...sel]).then((r) => {
1200
- 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` : ""}`);
1201
1240
  }).finally(() => setBusy(false));
1202
- } }, 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"))));
1203
1242
  }
1204
1243
  function useAssistant({ lang, T, muted, onToggleMute, sound, flash, setAlert, onReloadFriends }) {
1205
1244
  const [thread, setThread] = React.useState(() => [{ day: (/* @__PURE__ */ new Date()).toLocaleDateString() }, asMsg("them", helpText(lang))]);
@@ -1209,32 +1248,41 @@ ${tail}`;
1209
1248
  }, [lang]);
1210
1249
  const fetchFriends = async () => {
1211
1250
  const r = await fetch("/api/assistant/friends").then((x) => x.json());
1212
- const { people, nodes } = partitionPeers(r.friends || [], servicePeerMap(loadServicePeers()));
1251
+ const svcMap = servicePeerMap(await loadServicePeers());
1252
+ const { people, nodes } = partitionPeers(r.friends || [], svcMap);
1213
1253
  return {
1214
1254
  friends: people,
1215
1255
  nodes,
1216
1256
  all: r.friends || [],
1257
+ svcMap,
1217
1258
  historySince: r.historySince || null,
1218
1259
  now: r.now || Date.now(),
1219
1260
  ver: r.beagleVer || ""
1220
1261
  };
1221
1262
  };
1263
+ const skipLine = (skipped) => {
1264
+ if (!skipped.length)
1265
+ return "";
1266
+ const shown = skipped.slice(0, SKIP_NAMES_MAX).map(nameOf);
1267
+ const more = skipped.length - shown.length;
1268
+ const names = shown.join(", ") + (more > 0 ? lang === "zh" ? ` \u7B49 ${more} \u4F4D` : `, and ${more} more` : "");
1269
+ return lang === "zh" ? `
1270
+
1271
+ *\u8DF3\u8FC7\u4E86 ${skipped.length} \u4F4D\u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\uFF1A${names}\u3002*` : `
1272
+
1273
+ *Skipped ${skipped.length} added in the last ${RECENT_DAYS} days: ${names}.*`;
1274
+ };
1222
1275
  const listReply = (rows, skipped, data, title) => {
1223
1276
  if (!rows.length) {
1224
1277
  return `${title}
1225
1278
 
1226
- ${lang === "zh" ? "\u6CA1\u6709\u7B26\u5408\u7684\u597D\u53CB\u3002" : "No friends match."}
1279
+ ${lang === "zh" ? "\u6CA1\u6709\u7B26\u5408\u7684\u597D\u53CB\u3002" : "No friends match."}${skipLine(skipped)}
1227
1280
 
1228
1281
  ${sinceLine(data.historySince, lang)}`;
1229
1282
  }
1230
- const skip = skipped.length ? lang === "zh" ? `
1231
-
1232
- *\u8DF3\u8FC7\u4E86 ${skipped.length} \u4F4D\u6700\u8FD1 ${RECENT_DAYS} \u5929\u5185\u6DFB\u52A0\u7684\u597D\u53CB\u3002*` : `
1233
-
1234
- *${skipped.length} friend${skipped.length > 1 ? "s" : ""} added in the last ${RECENT_DAYS} days ${skipped.length > 1 ? "were" : "was"} skipped.*` : "";
1235
1283
  return `${title}
1236
1284
 
1237
- ${friendTable(rows, data.now, lang)}${skip}
1285
+ ${friendTable(rows, data.now, lang)}${skipLine(skipped)}
1238
1286
 
1239
1287
  ${sinceLine(data.historySince, lang)}`;
1240
1288
  };
@@ -1273,6 +1321,33 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1273
1321
  - Waiting to be accepted: ${pending}
1274
1322
  - History on this device since: ${fmtDate(d.historySince, lang)}
1275
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 } }));
1276
1351
  }
1277
1352
  case "nodes-list":
1278
1353
  case "friends-list": {
@@ -1362,6 +1437,24 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1362
1437
  return append(asMsg("them", lang === "zh" ? `\u51FA\u9519\u4E86\uFF1A${e && e.message || e}` : `That failed: ${e && e.message || e}`));
1363
1438
  }
1364
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]);
1365
1458
  const removeFriends = React.useCallback(async (ids) => {
1366
1459
  let removed = 0;
1367
1460
  const failed = [];
@@ -1381,7 +1474,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
1381
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"}.`));
1382
1475
  return { removed, failed: failed.length };
1383
1476
  }, [append, lang, onReloadFriends]);
1384
- return { thread, handle, removeFriends };
1477
+ return { thread, handle, removeFriends, adoptServicePeers };
1385
1478
  }
1386
1479
  var DK_CMD_HIST_KEY = "beagle.assistant.history";
1387
1480
  var DK_CMD_HIST_MAX = 50;
@@ -2188,7 +2281,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2188
2281
  return null;
2189
2282
  }), err && /* @__PURE__ */ React.createElement("div", { style: { color: "var(--danger, #ff6b6b)", fontSize: 11.5, fontFamily: "var(--mono)" } }, err));
2190
2283
  }
2191
- 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 }) {
2192
2285
  const mine = m.from === "me";
2193
2286
  const nativeMedia = !m.file ? dkNativeMediaFromText(m.text) : null;
2194
2287
  const grp = isGroup && m.dir !== "out" && !m.file && !nativeMedia ? dkGroupParse(m.text) : null;
@@ -2517,7 +2610,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2517
2610
  letterSpacing: -0.1,
2518
2611
  wordBreak: "break-word"
2519
2612
  } }, /* @__PURE__ */ React.createElement(MarkdownText, { text: read.text, onNameCard })));
2520
- })(), 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), (() => {
2521
2614
  const sentDone = mine && m.status && m.status !== "queued" && m.status !== "sending" && m.status !== "failed";
2522
2615
  const via = !mine ? m.via : sentDone ? m.via === "offline" ? "offline" : "online" : null;
2523
2616
  if (!via)
@@ -2562,7 +2655,7 @@ ${cands.map((c) => `- ${nameOf(c)}`).join("\n")}`;
2562
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)" })))
2563
2656
  );
2564
2657
  }
2565
- 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 }) {
2566
2659
  const scrollRef = React.useRef(null);
2567
2660
  const prependRef = React.useRef(null);
2568
2661
  const [earlierBusy, setEarlierBusy] = React.useState(false);
@@ -2995,6 +3088,7 @@ ${peer.address}`
2995
3088
  isGroup,
2996
3089
  onTheater: setTheater,
2997
3090
  onAssistantRemove,
3091
+ onAssistantAdopt,
2998
3092
  onDelete: (id) => doDelete([id]),
2999
3093
  onCancel: doCancel,
3000
3094
  onRetry: doRetry,
@@ -3154,12 +3248,12 @@ ${peer.address}`
3154
3248
  function ChatEmpty({ T }) {
3155
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));
3156
3250
  }
3157
- 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 }) {
3158
3252
  const peer = peers.find((p) => p.id === activeId);
3159
3253
  if (phone) {
3160
- 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 });
3161
3255
  }
3162
- 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 }));
3163
3257
  }
3164
3258
  Object.assign(window, { ChatTab });
3165
3259
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
@@ -6756,7 +6850,7 @@ ${peer.address}`
6756
6850
  fontWeight: 600,
6757
6851
  background: tab === n.id ? "var(--accent)" : "transparent",
6758
6852
  color: tab === n.id ? "#fff" : "var(--dim)"
6759
- } }, /* @__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) => {
6760
6854
  data.refresh();
6761
6855
  return r;
6762
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.110",
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.24",
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",