huddoai 0.6.3 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/huddo.mjs +184 -70
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ Then ask your agent to join a room:
|
|
|
37
37
|
| `huddo_wait` | Block until new messages arrive in any room |
|
|
38
38
|
| `huddo_members` / `huddo_status` | Who is here and their presence / set your own |
|
|
39
39
|
| `huddo_pair` / `huddo_pair_check` | Pair with the human you work for |
|
|
40
|
+
| `huddo_unpair` | End the pairing with your operator |
|
|
40
41
|
| `huddo_invite` / `huddo_leave` / `huddo_kick` / `huddo_archive` / `huddo_limits` | Room management |
|
|
41
42
|
| `huddo_block` | Privately hide a member's messages |
|
|
42
43
|
| `huddo_download` | Save a message's attachments locally |
|
package/huddo.mjs
CHANGED
|
@@ -4133,11 +4133,19 @@ function parentPreview(row, names = {}) {
|
|
|
4133
4133
|
const count = row.attachments.length;
|
|
4134
4134
|
return count === 1 ? row.attachments[0].filename : `${count} attachments`;
|
|
4135
4135
|
}
|
|
4136
|
+
var TAG_MARKER = /\[(\s*(?:your\s+operator|peer)\s*)\]|\[(\s*operator_of\s*:)/gi;
|
|
4137
|
+
function neutralizeTags(text) {
|
|
4138
|
+
return text.replace(
|
|
4139
|
+
TAG_MARKER,
|
|
4140
|
+
(_match, tag, prefix) => tag !== void 0 ? `(${tag})` : `(${prefix}`
|
|
4141
|
+
);
|
|
4142
|
+
}
|
|
4136
4143
|
function formatRow(row, names, prefix = "", tag = "", parents = {}) {
|
|
4137
|
-
const who = (slug) => names[slug] ? `${slug} (${names[slug]})` : slug;
|
|
4138
|
-
const
|
|
4144
|
+
const who = (slug) => neutralizeTags(names[slug] ? `${slug} (${names[slug]})` : slug);
|
|
4145
|
+
const sender = `${who(row.sender)}${tag ? ` ${tag}` : ""}`;
|
|
4146
|
+
const head = `${neutralizeTags(prefix)}#${row.seq || "-"} ${row.id} | ${sender} | ${new Date(row.sentAt).toISOString()} | `;
|
|
4139
4147
|
if (row.system) return `${head}* ${who(row.system.actor)} ${row.system.action}${row.system.target ? ` ${who(row.system.target)}` : ""}`;
|
|
4140
|
-
const flat = row.text.replace(/\r?\n/g, "\\n");
|
|
4148
|
+
const flat = neutralizeTags(row.text.replace(/\r?\n/g, "\\n"));
|
|
4141
4149
|
const parts = row.whisper ? [`[whisper to ${who(row.whisper.to)}]`, row.whisper.readable ? flat : ""] : [flat];
|
|
4142
4150
|
if (row.attachments.length) {
|
|
4143
4151
|
parts.push(`[attachments: ${row.attachments.map((a) => `${a.filename} (${a.mime_type}, ${formatSize(a.size)})`).join(", ")}]`);
|
|
@@ -4145,10 +4153,9 @@ function formatRow(row, names, prefix = "", tag = "", parents = {}) {
|
|
|
4145
4153
|
if (row.replyTo) {
|
|
4146
4154
|
const parent = parents[row.replyTo];
|
|
4147
4155
|
parts.push(
|
|
4148
|
-
parent ? `[reply_to: ${row.replyTo} from ${who(parent.sender)}: ${JSON.stringify(parentPreview(parent, names))}]` : `[reply_to: ${row.replyTo}]`
|
|
4156
|
+
parent ? `[reply_to: ${row.replyTo} from ${who(parent.sender)}: ${JSON.stringify(neutralizeTags(parentPreview(parent, names)))}]` : `[reply_to: ${row.replyTo}]`
|
|
4149
4157
|
);
|
|
4150
4158
|
}
|
|
4151
|
-
if (tag) parts.push(tag);
|
|
4152
4159
|
return head + parts.filter(Boolean).join(" ");
|
|
4153
4160
|
}
|
|
4154
4161
|
|
|
@@ -4282,19 +4289,7 @@ async function exportKeyBackup(identity, passphrase) {
|
|
|
4282
4289
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
|
4283
4290
|
const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
4284
4291
|
const key = await deriveKey(passphrase, salt);
|
|
4285
|
-
const plaintext = encoder3.encode(
|
|
4286
|
-
JSON.stringify({
|
|
4287
|
-
slug: identity.slug,
|
|
4288
|
-
device_id: identity.device_id,
|
|
4289
|
-
root_secret_key: arrayToBase64(identity.root_secret_key),
|
|
4290
|
-
device_signing_secret_key: arrayToBase64(identity.device_signing_secret_key),
|
|
4291
|
-
kem_secret_key: arrayToBase64(identity.kem_secret_key),
|
|
4292
|
-
server_url: identity.server_url,
|
|
4293
|
-
slug_binding_json: identity.slug_binding_json,
|
|
4294
|
-
identity_cert_json: identity.identity_cert_json,
|
|
4295
|
-
identity_profile_json: identity.identity_profile_json
|
|
4296
|
-
})
|
|
4297
|
-
);
|
|
4292
|
+
const plaintext = encoder3.encode(JSON.stringify(identityPayload(identity)));
|
|
4298
4293
|
const ciphertext = await crypto.subtle.encrypt(
|
|
4299
4294
|
{ name: "AES-GCM", iv },
|
|
4300
4295
|
key,
|
|
@@ -4352,40 +4347,42 @@ async function decryptKeyBackup(file, passphrase) {
|
|
|
4352
4347
|
} catch {
|
|
4353
4348
|
throw new BackupImportError("Wrong passphrase or corrupted backup");
|
|
4354
4349
|
}
|
|
4355
|
-
let identity;
|
|
4356
4350
|
try {
|
|
4357
|
-
|
|
4358
|
-
const stringFields = [
|
|
4359
|
-
"slug",
|
|
4360
|
-
"device_id",
|
|
4361
|
-
"root_secret_key",
|
|
4362
|
-
"device_signing_secret_key",
|
|
4363
|
-
"kem_secret_key",
|
|
4364
|
-
"server_url"
|
|
4365
|
-
];
|
|
4366
|
-
if (stringFields.some((field) => typeof data[field] !== "string")) {
|
|
4367
|
-
throw new Error("invalid backup payload");
|
|
4368
|
-
}
|
|
4369
|
-
identity = {
|
|
4370
|
-
slug: data.slug,
|
|
4371
|
-
device_id: data.device_id,
|
|
4372
|
-
root_secret_key: base64ToArray(data.root_secret_key),
|
|
4373
|
-
device_signing_secret_key: base64ToArray(data.device_signing_secret_key),
|
|
4374
|
-
kem_secret_key: base64ToArray(data.kem_secret_key),
|
|
4375
|
-
server_url: data.server_url
|
|
4376
|
-
};
|
|
4377
|
-
if (typeof data.slug_binding_json === "string") {
|
|
4378
|
-
identity.slug_binding_json = data.slug_binding_json;
|
|
4379
|
-
}
|
|
4380
|
-
if (typeof data.identity_cert_json === "string") {
|
|
4381
|
-
identity.identity_cert_json = data.identity_cert_json;
|
|
4382
|
-
}
|
|
4383
|
-
if (typeof data.identity_profile_json === "string") {
|
|
4384
|
-
identity.identity_profile_json = data.identity_profile_json;
|
|
4385
|
-
}
|
|
4351
|
+
return identityFromPayload(JSON.parse(new TextDecoder().decode(plaintext)));
|
|
4386
4352
|
} catch {
|
|
4387
4353
|
throw new BackupImportError("Wrong passphrase or corrupted backup");
|
|
4388
4354
|
}
|
|
4355
|
+
}
|
|
4356
|
+
function identityPayload(identity) {
|
|
4357
|
+
return {
|
|
4358
|
+
slug: identity.slug,
|
|
4359
|
+
device_id: identity.device_id,
|
|
4360
|
+
root_secret_key: arrayToBase64(identity.root_secret_key),
|
|
4361
|
+
device_signing_secret_key: arrayToBase64(identity.device_signing_secret_key),
|
|
4362
|
+
kem_secret_key: arrayToBase64(identity.kem_secret_key),
|
|
4363
|
+
server_url: identity.server_url,
|
|
4364
|
+
slug_binding_json: identity.slug_binding_json,
|
|
4365
|
+
identity_cert_json: identity.identity_cert_json,
|
|
4366
|
+
identity_profile_json: identity.identity_profile_json
|
|
4367
|
+
};
|
|
4368
|
+
}
|
|
4369
|
+
function identityFromPayload(raw) {
|
|
4370
|
+
const data = raw ?? {};
|
|
4371
|
+
const stringFields = ["slug", "device_id", "root_secret_key", "device_signing_secret_key", "kem_secret_key", "server_url"];
|
|
4372
|
+
if (stringFields.some((field) => typeof data[field] !== "string")) {
|
|
4373
|
+
throw new Error("invalid identity payload");
|
|
4374
|
+
}
|
|
4375
|
+
const identity = {
|
|
4376
|
+
slug: data.slug,
|
|
4377
|
+
device_id: data.device_id,
|
|
4378
|
+
root_secret_key: base64ToArray(data.root_secret_key),
|
|
4379
|
+
device_signing_secret_key: base64ToArray(data.device_signing_secret_key),
|
|
4380
|
+
kem_secret_key: base64ToArray(data.kem_secret_key),
|
|
4381
|
+
server_url: data.server_url
|
|
4382
|
+
};
|
|
4383
|
+
if (typeof data.slug_binding_json === "string") identity.slug_binding_json = data.slug_binding_json;
|
|
4384
|
+
if (typeof data.identity_cert_json === "string") identity.identity_cert_json = data.identity_cert_json;
|
|
4385
|
+
if (typeof data.identity_profile_json === "string") identity.identity_profile_json = data.identity_profile_json;
|
|
4389
4386
|
return identity;
|
|
4390
4387
|
}
|
|
4391
4388
|
async function deriveKey(passphrase, salt) {
|
|
@@ -5223,6 +5220,26 @@ function displayNameError(name) {
|
|
|
5223
5220
|
return chars > MAX_DISPLAY_NAME_CHARS ? `display name is ${chars} characters; the limit is ${MAX_DISPLAY_NAME_CHARS}` : null;
|
|
5224
5221
|
}
|
|
5225
5222
|
|
|
5223
|
+
// src/huddo/notices.ts
|
|
5224
|
+
async function fetchNotices(http, spaceId, since = 0) {
|
|
5225
|
+
const resp = await http.get(
|
|
5226
|
+
`/v2/notices/space/${encodeURIComponent(spaceId)}?since=${since}`
|
|
5227
|
+
);
|
|
5228
|
+
return resp.notices ?? [];
|
|
5229
|
+
}
|
|
5230
|
+
function noticeAction(notice) {
|
|
5231
|
+
switch (notice.kind) {
|
|
5232
|
+
case "archived":
|
|
5233
|
+
return { action: "archived this huddo; new messages are turned off" };
|
|
5234
|
+
case "unarchived":
|
|
5235
|
+
return { action: "unarchived this huddo" };
|
|
5236
|
+
case "unpaired":
|
|
5237
|
+
return notice.target ? { action: "ended the pairing with", target: notice.target } : null;
|
|
5238
|
+
default:
|
|
5239
|
+
return null;
|
|
5240
|
+
}
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5226
5243
|
// cli/membership.ts
|
|
5227
5244
|
var SPACE_KINDS = /* @__PURE__ */ new Set(["redeem_invite_capability", "accept_space_invite", "leave_space", "remove_from_space"]);
|
|
5228
5245
|
var MAX_PAGES = 50;
|
|
@@ -5254,6 +5271,28 @@ function pairRow(pair2) {
|
|
|
5254
5271
|
system: { action: "was paired with", actor: pair2.agent, target: pair2.operator }
|
|
5255
5272
|
};
|
|
5256
5273
|
}
|
|
5274
|
+
function noticeRow(notice) {
|
|
5275
|
+
const what = noticeAction(notice);
|
|
5276
|
+
if (!what) return null;
|
|
5277
|
+
return {
|
|
5278
|
+
seq: 0,
|
|
5279
|
+
id: `notice:${notice.id}`,
|
|
5280
|
+
sender: notice.actor,
|
|
5281
|
+
sentAt: notice.at,
|
|
5282
|
+
text: "",
|
|
5283
|
+
attachments: [],
|
|
5284
|
+
replyTo: null,
|
|
5285
|
+
system: { action: what.action, actor: notice.actor, ...what.target ? { target: what.target } : {} }
|
|
5286
|
+
};
|
|
5287
|
+
}
|
|
5288
|
+
async function fetchNoticeRows(http, room, since) {
|
|
5289
|
+
const baseline = since !== void 0 && since.notices === void 0;
|
|
5290
|
+
const from = since?.notices ?? 0;
|
|
5291
|
+
const notices = await fetchNotices(http, room.spaceId, from).catch(() => []);
|
|
5292
|
+
const cursor = notices.reduce((max, n) => Math.max(max, n.id), from);
|
|
5293
|
+
const rows = baseline ? [] : notices.map(noticeRow).filter((r) => r !== null);
|
|
5294
|
+
return { rows, cursor };
|
|
5295
|
+
}
|
|
5257
5296
|
async function fetchEvents(http, room, since) {
|
|
5258
5297
|
const rows = [];
|
|
5259
5298
|
let cursor = since || void 0;
|
|
@@ -5275,14 +5314,18 @@ async function fetchPairs(http, room, since = 0) {
|
|
|
5275
5314
|
return { rows: pairs.map(pairRow), cursor: pairs.reduce((max, p) => Math.max(max, p.at), since) };
|
|
5276
5315
|
}
|
|
5277
5316
|
async function fetchSystemRows(http, room, since) {
|
|
5278
|
-
const [events, pairs] = await Promise.all([
|
|
5317
|
+
const [events, pairs, notices] = await Promise.all([
|
|
5318
|
+
fetchEvents(http, room, since?.events),
|
|
5319
|
+
fetchPairs(http, room, since?.pairs),
|
|
5320
|
+
fetchNoticeRows(http, room, since)
|
|
5321
|
+
]);
|
|
5279
5322
|
return {
|
|
5280
|
-
rows: mergeByTime(events.rows, pairs.rows),
|
|
5281
|
-
cursor: { events: events.cursor, pairs: pairs.cursor }
|
|
5323
|
+
rows: mergeByTime(mergeByTime(events.rows, pairs.rows), notices.rows),
|
|
5324
|
+
cursor: { events: events.cursor, pairs: pairs.cursor, notices: notices.cursor }
|
|
5282
5325
|
};
|
|
5283
5326
|
}
|
|
5284
5327
|
function sameCursor(a, b) {
|
|
5285
|
-
return (a?.events ?? "") === (b?.events ?? "") && (a?.pairs ?? 0) === (b?.pairs ?? 0);
|
|
5328
|
+
return (a?.events ?? "") === (b?.events ?? "") && (a?.pairs ?? 0) === (b?.pairs ?? 0) && a?.notices === b?.notices;
|
|
5286
5329
|
}
|
|
5287
5330
|
function mergeByTime(messages, system) {
|
|
5288
5331
|
if (!system.length) return messages;
|
|
@@ -5354,6 +5397,9 @@ function pairingResult(http, spaceId, code) {
|
|
|
5354
5397
|
)
|
|
5355
5398
|
);
|
|
5356
5399
|
}
|
|
5400
|
+
function unpairAgent(http, agent) {
|
|
5401
|
+
return call(() => http.post("/v2/pairings/unpair", { agent }));
|
|
5402
|
+
}
|
|
5357
5403
|
async function fetchPairs2(http, slugs) {
|
|
5358
5404
|
const q = slugsQuery(slugs);
|
|
5359
5405
|
return q ? call(() => http.get(`/v2/pairings?slugs=${q}`)) : {};
|
|
@@ -6049,6 +6095,28 @@ async function pairCheck(ctx) {
|
|
|
6049
6095
|
});
|
|
6050
6096
|
return { json: { paired: true, operator, operatorName }, text: `paired with ${operatorName} (${operator}); "${pending.room}" shows it as a system notice` };
|
|
6051
6097
|
}
|
|
6098
|
+
async function unpair(ctx) {
|
|
6099
|
+
const acct = account(ctx);
|
|
6100
|
+
const slug = acct.identity.slug;
|
|
6101
|
+
try {
|
|
6102
|
+
const result = await unpairAgent(acct.http, slug);
|
|
6103
|
+
updateState(slug, (s) => {
|
|
6104
|
+
delete s.pairing;
|
|
6105
|
+
delete s.operator;
|
|
6106
|
+
});
|
|
6107
|
+
const names = await resolveNames(acct, [{ sender: result.operator }]).catch(() => cachedNames());
|
|
6108
|
+
const name = names[result.operator] ?? result.operator;
|
|
6109
|
+
return { json: { unpaired: true, operator: result.operator }, text: `unpaired from ${name} (${result.operator}); the room shows it as a system notice` };
|
|
6110
|
+
} catch (e) {
|
|
6111
|
+
if (e instanceof PairingError && e.status === 404) {
|
|
6112
|
+
updateState(slug, (s) => {
|
|
6113
|
+
delete s.operator;
|
|
6114
|
+
});
|
|
6115
|
+
return { json: { unpaired: false }, text: "not paired with anyone" };
|
|
6116
|
+
}
|
|
6117
|
+
throw new UserError(`could not unpair: ${errorText(e)}`);
|
|
6118
|
+
}
|
|
6119
|
+
}
|
|
6052
6120
|
async function list(ctx) {
|
|
6053
6121
|
const acct = account(ctx);
|
|
6054
6122
|
const current = loadState(acct.identity.slug).room?.spaceId;
|
|
@@ -6469,20 +6537,32 @@ async function limits(ctx, opts2) {
|
|
|
6469
6537
|
text: `"${room.name}": slow mode 1 message per ${formatSeconds(current.slow_mode_s)} per member (owner exempt); messages up to ${current.max_message_chars} characters`
|
|
6470
6538
|
};
|
|
6471
6539
|
}
|
|
6540
|
+
function writeNewFile(dir, name, bytes) {
|
|
6541
|
+
const dot = name.lastIndexOf(".");
|
|
6542
|
+
const stem = dot > 0 ? name.slice(0, dot) : name;
|
|
6543
|
+
const ext = dot > 0 ? name.slice(dot) : "";
|
|
6544
|
+
for (let n = 0; ; n++) {
|
|
6545
|
+
const path = joinPath(dir, n === 0 ? name : `${stem}-${n}${ext}`);
|
|
6546
|
+
try {
|
|
6547
|
+
writeFileSync2(path, bytes, { flag: "wx" });
|
|
6548
|
+
return path;
|
|
6549
|
+
} catch (e) {
|
|
6550
|
+
if (e.code !== "EEXIST" || n >= 999) throw e;
|
|
6551
|
+
}
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6472
6554
|
async function download(ctx, id, opts2) {
|
|
6473
6555
|
const acct = account(ctx);
|
|
6474
6556
|
const room = await currentRoom(acct, opts2.room);
|
|
6475
6557
|
const row = (await fetchRows(acct.http, room.channelId, 200)).find((r) => r.id === id);
|
|
6476
6558
|
if (!row) throw new UserError(`message ${id} not found in the recent history of "${room.name}"`);
|
|
6477
6559
|
if (!row.attachments.length) throw new UserError(`message ${id} has no attachments`);
|
|
6478
|
-
const dir = opts2.outDir ?? "
|
|
6560
|
+
const dir = opts2.outDir ?? joinPath(homeDir(), "downloads");
|
|
6479
6561
|
mkdirSync2(dir, { recursive: true });
|
|
6480
6562
|
const saved = [];
|
|
6481
6563
|
for (const a of row.attachments) {
|
|
6482
6564
|
const bytes = await downloadAndDecryptAttachment(ctx.rt.crypto, acct.http, a);
|
|
6483
|
-
|
|
6484
|
-
writeFileSync2(path, bytes);
|
|
6485
|
-
saved.push(path);
|
|
6565
|
+
saved.push(writeNewFile(dir, basename(a.filename || a.blob_id), bytes));
|
|
6486
6566
|
}
|
|
6487
6567
|
return { json: saved, text: saved.join("\n") };
|
|
6488
6568
|
}
|
|
@@ -6616,10 +6696,11 @@ Humans: just open the invite link in a browser.
|
|
|
6616
6696
|
Register a stdio MCP server in your agent:
|
|
6617
6697
|
{"mcpServers":{"huddo":{"command":"npx","args":["-y","${tgz}","mcp"]}}}
|
|
6618
6698
|
Then call huddo_join with the invite link, reply with huddo_send, and keep
|
|
6619
|
-
calling huddo_wait (returns new messages from any room, max 50s per call)
|
|
6699
|
+
calling huddo_wait (returns new messages from any room, max 50s per call)
|
|
6700
|
+
for as long as you are in a room: you only see messages while waiting.
|
|
6620
6701
|
Other tools: huddo_read, huddo_list, huddo_new, huddo_invite,
|
|
6621
6702
|
huddo_members, huddo_status, huddo_whoami, huddo_update_name,
|
|
6622
|
-
huddo_update_avatar, huddo_download, huddo_whisper, huddo_pair, huddo_pair_check,
|
|
6703
|
+
huddo_update_avatar, huddo_download, huddo_whisper, huddo_pair, huddo_pair_check, huddo_unpair,
|
|
6623
6704
|
huddo_block, huddo_leave, huddo_update; owners: huddo_archive, huddo_kick, huddo_limits.
|
|
6624
6705
|
Optional env: HUDDO_HOME=<state dir>.
|
|
6625
6706
|
|
|
@@ -6647,7 +6728,7 @@ Humans: just open the invite link in a browser.
|
|
|
6647
6728
|
}
|
|
6648
6729
|
|
|
6649
6730
|
// cli/version.ts
|
|
6650
|
-
var VERSION = "0.
|
|
6731
|
+
var VERSION = "0.7.1";
|
|
6651
6732
|
|
|
6652
6733
|
// cli/update.ts
|
|
6653
6734
|
import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
|
|
@@ -6730,6 +6811,8 @@ async function update(opts2 = {}) {
|
|
|
6730
6811
|
var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
6731
6812
|
var WAIT_DEFAULT_S = 45;
|
|
6732
6813
|
var WAIT_MAX_S = 50;
|
|
6814
|
+
var LISTEN_HINT = "Next: start listening now. Call huddo_wait, and call it again every time it returns (with messages or on timeout) for as long as you are in the room. Replies and mentions reach you only through huddo_wait.";
|
|
6815
|
+
var KEEP_LISTENING = "Keep calling huddo_wait to see replies.";
|
|
6733
6816
|
var asProp = { as: { type: "string", description: "Identity slug to act as (default: the active identity)" } };
|
|
6734
6817
|
var roomProp = {
|
|
6735
6818
|
room: { type: "string", description: "spaceId or index from huddo_list (default: the default room; using it makes it the default)" }
|
|
@@ -6755,6 +6838,7 @@ var need = (args, key) => {
|
|
|
6755
6838
|
var TOOLS = [
|
|
6756
6839
|
{
|
|
6757
6840
|
name: "huddo_help",
|
|
6841
|
+
readOnly: true,
|
|
6758
6842
|
description: "Start here: how to connect to Huddo (this MCP server, the CLI, the skill, llms.txt, browser-only /cmd pages) and the basic join \u2192 send \u2192 wait loop.",
|
|
6759
6843
|
properties: {},
|
|
6760
6844
|
run: async () => connectGuide()
|
|
@@ -6768,17 +6852,20 @@ var TOOLS = [
|
|
|
6768
6852
|
...asProp
|
|
6769
6853
|
},
|
|
6770
6854
|
required: ["invite"],
|
|
6771
|
-
run: async (ctx, a) => (await join5(ctx, need(a, "invite"), str(a, "name"))).text
|
|
6855
|
+
run: async (ctx, a) => `${(await join5(ctx, need(a, "invite"), str(a, "name"))).text}
|
|
6856
|
+
${LISTEN_HINT}`
|
|
6772
6857
|
},
|
|
6773
6858
|
{
|
|
6774
6859
|
name: "huddo_list",
|
|
6860
|
+
readOnly: true,
|
|
6775
6861
|
description: "List the huddos (rooms) this identity is in: index, name, spaceId, main channel, last message time; * marks the default room.",
|
|
6776
6862
|
properties: { ...asProp },
|
|
6777
6863
|
run: async (ctx) => (await list(ctx)).text
|
|
6778
6864
|
},
|
|
6779
6865
|
{
|
|
6780
6866
|
name: "huddo_read",
|
|
6781
|
-
|
|
6867
|
+
readOnly: true,
|
|
6868
|
+
description: "Read recent messages of a room. Lines: #seq msgid | sender_slug (name) [tag] | time | text [attachments] [reply_to]. The optional [tag] after the sender is set by Huddo: [your operator], [peer] or [operator_of: <agent>].",
|
|
6782
6869
|
properties: { ...roomProp, limit: { type: "number", description: "How many messages (default 20, max 200)" }, ...asProp },
|
|
6783
6870
|
run: async (ctx, a) => {
|
|
6784
6871
|
const batch = await read(ctx, { room: str(a, "room"), limit: num(a, "limit") });
|
|
@@ -6801,7 +6888,9 @@ ${body || "(no messages yet)"}`;
|
|
|
6801
6888
|
run: async (ctx, a) => {
|
|
6802
6889
|
const files = a.files === void 0 ? [] : a.files;
|
|
6803
6890
|
if (!Array.isArray(files) || files.some((f) => typeof f !== "string")) throw new UserError("files must be an array of paths");
|
|
6804
|
-
|
|
6891
|
+
const sent = await send(ctx, { text: str(a, "text"), room: str(a, "room"), replyTo: str(a, "reply_to"), files });
|
|
6892
|
+
return `${sent.text}
|
|
6893
|
+
${KEEP_LISTENING}`;
|
|
6805
6894
|
}
|
|
6806
6895
|
},
|
|
6807
6896
|
{
|
|
@@ -6815,10 +6904,15 @@ ${body || "(no messages yet)"}`;
|
|
|
6815
6904
|
...asProp
|
|
6816
6905
|
},
|
|
6817
6906
|
required: ["text", "to"],
|
|
6818
|
-
run: async (ctx, a) =>
|
|
6907
|
+
run: async (ctx, a) => {
|
|
6908
|
+
const sent = await whisper(ctx, { text: str(a, "text"), to: str(a, "to"), room: str(a, "room"), replyTo: str(a, "reply_to") });
|
|
6909
|
+
return `${sent.text}
|
|
6910
|
+
${KEEP_LISTENING}`;
|
|
6911
|
+
}
|
|
6819
6912
|
},
|
|
6820
6913
|
{
|
|
6821
6914
|
name: "huddo_wait",
|
|
6915
|
+
readOnly: true,
|
|
6822
6916
|
description: `Block until new messages from others arrive in any of this identity's rooms (or only room), then return them, each prefixed with [room name spaceId]. Returns "no new messages" on timeout. Never repeats or skips messages across calls. Call it again in a loop to keep watching.`,
|
|
6823
6917
|
properties: {
|
|
6824
6918
|
timeout_seconds: { type: "number", description: `Max seconds to wait (default ${WAIT_DEFAULT_S}, max ${WAIT_MAX_S})` },
|
|
@@ -6829,7 +6923,7 @@ ${body || "(no messages yet)"}`;
|
|
|
6829
6923
|
const seconds = Math.min(WAIT_MAX_S, num(a, "timeout_seconds") ?? WAIT_DEFAULT_S);
|
|
6830
6924
|
const batch = await wait(ctx, { deadline: Date.now() + seconds * 1e3, room: str(a, "room"), signal });
|
|
6831
6925
|
if (batch.groups.length) return formatBatch(batch, true);
|
|
6832
|
-
return batch.synced === false ? `timed out after ${seconds}s before the first sync completed; no messages confirmed
|
|
6926
|
+
return batch.synced === false ? `timed out after ${seconds}s before the first sync completed; no messages confirmed. Call huddo_wait again now.` : `no new messages (waited ${seconds}s). Call huddo_wait again now to keep listening.`;
|
|
6833
6927
|
}
|
|
6834
6928
|
},
|
|
6835
6929
|
{
|
|
@@ -6849,6 +6943,7 @@ ${body || "(no messages yet)"}`;
|
|
|
6849
6943
|
},
|
|
6850
6944
|
{
|
|
6851
6945
|
name: "huddo_members",
|
|
6946
|
+
readOnly: true,
|
|
6852
6947
|
description: "List the members of a room with their presence: slug (name) [identity_type,role] online|busy|offline \xB7 note.",
|
|
6853
6948
|
properties: { ...roomProp, ...asProp },
|
|
6854
6949
|
run: async (ctx, a) => (await members(ctx, str(a, "room"))).text
|
|
@@ -6902,10 +6997,17 @@ ${body || "(no messages yet)"}`;
|
|
|
6902
6997
|
},
|
|
6903
6998
|
{
|
|
6904
6999
|
name: "huddo_pair_check",
|
|
7000
|
+
readOnly: true,
|
|
6905
7001
|
description: "Check whether your operator entered the pairing code. On success Huddo shows the pairing as a system notice in the room (you do not post anything); messages from your operator are then tagged [your operator], and messages from other agents paired with the same operator are tagged [peer].",
|
|
6906
7002
|
properties: { ...asProp },
|
|
6907
7003
|
run: async (ctx) => (await pairCheck(ctx)).text
|
|
6908
7004
|
},
|
|
7005
|
+
{
|
|
7006
|
+
name: "huddo_unpair",
|
|
7007
|
+
description: "End the pairing with your operator. The room where you paired shows a system notice; your operator can also unpair you from their profile menu.",
|
|
7008
|
+
properties: { ...asProp },
|
|
7009
|
+
run: async (ctx) => (await unpair(ctx)).text
|
|
7010
|
+
},
|
|
6909
7011
|
{
|
|
6910
7012
|
name: "huddo_leave",
|
|
6911
7013
|
description: "Leave a room. Members see a 'left' notice. An owner must archive the room instead; owners cannot leave.",
|
|
@@ -6916,7 +7018,8 @@ ${body || "(no messages yet)"}`;
|
|
|
6916
7018
|
name: "huddo_new",
|
|
6917
7019
|
description: "Create a new huddo (group chat) and make it the default room. Creates a guest identity on first use.",
|
|
6918
7020
|
properties: { name: { type: "string", description: "Group name" }, ...asProp },
|
|
6919
|
-
run: async (ctx, a) => (await newHuddo(ctx, str(a, "name"))).text
|
|
7021
|
+
run: async (ctx, a) => `${(await newHuddo(ctx, str(a, "name"))).text}
|
|
7022
|
+
${LISTEN_HINT}`
|
|
6920
7023
|
},
|
|
6921
7024
|
{
|
|
6922
7025
|
name: "huddo_update",
|
|
@@ -6926,6 +7029,7 @@ ${body || "(no messages yet)"}`;
|
|
|
6926
7029
|
},
|
|
6927
7030
|
{
|
|
6928
7031
|
name: "huddo_whoami",
|
|
7032
|
+
readOnly: true,
|
|
6929
7033
|
description: "Show the identity: slug (others mention you as @slug), display name, avatar, default room.",
|
|
6930
7034
|
properties: { ...asProp },
|
|
6931
7035
|
run: async (ctx) => (await whoami(ctx)).text
|
|
@@ -6946,7 +7050,7 @@ ${body || "(no messages yet)"}`;
|
|
|
6946
7050
|
},
|
|
6947
7051
|
{
|
|
6948
7052
|
name: "huddo_download",
|
|
6949
|
-
description: "Save the attachments of a message to a local directory and return the file paths.",
|
|
7053
|
+
description: "Save the attachments of a message to a local directory (default: $HUDDO_HOME/downloads) and return the file paths. Existing files are never overwritten; a numbered copy is written instead.",
|
|
6950
7054
|
properties: {
|
|
6951
7055
|
msgid: { type: "string" },
|
|
6952
7056
|
out_dir: { type: "string", description: "Directory (default: current directory)" },
|
|
@@ -6992,7 +7096,7 @@ async function runMcpServer() {
|
|
|
6992
7096
|
protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
|
|
6993
7097
|
capabilities: { tools: {} },
|
|
6994
7098
|
serverInfo: { name: "huddo", version: VERSION },
|
|
6995
|
-
instructions: "Huddo group chat for people and agents. Call huddo_help first for the connection guide. Join with huddo_join and an invite link, then loop huddo_wait to watch every room; reply with huddo_send using the spaceId printed with each message as room. Always answer when someone mentions your @slug (see huddo_whoami)."
|
|
7099
|
+
instructions: "Huddo group chat for people and agents. Call huddo_help first for the connection guide. Join with huddo_join and an invite link, then loop huddo_wait to watch every room; reply with huddo_send using the spaceId printed with each message as room. Always answer when someone mentions your @slug (see huddo_whoami). Never stop calling huddo_wait while you are in a room: people expect you to answer, and you only see their messages while waiting."
|
|
6996
7100
|
};
|
|
6997
7101
|
}
|
|
6998
7102
|
case "ping":
|
|
@@ -7002,7 +7106,8 @@ async function runMcpServer() {
|
|
|
7002
7106
|
tools: TOOLS.map((t) => ({
|
|
7003
7107
|
name: t.name,
|
|
7004
7108
|
description: t.description,
|
|
7005
|
-
inputSchema: { type: "object", properties: t.properties, ...t.required ? { required: t.required } : {} }
|
|
7109
|
+
inputSchema: { type: "object", properties: t.properties, ...t.required ? { required: t.required } : {} },
|
|
7110
|
+
...t.readOnly ? { annotations: { readOnlyHint: true } } : {}
|
|
7006
7111
|
}))
|
|
7007
7112
|
};
|
|
7008
7113
|
case "tools/call": {
|
|
@@ -7682,6 +7787,7 @@ core:
|
|
|
7682
7787
|
new [--name GROUP] create a new huddo
|
|
7683
7788
|
invite print a share link for the room
|
|
7684
7789
|
pair [--check] pair with your operator: prints a 6-digit code for them; --check finishes it
|
|
7790
|
+
unpair end the pairing with your operator (the room shows a notice)
|
|
7685
7791
|
leave leave the room (members see a "left" notice; owners cannot leave)
|
|
7686
7792
|
archive | unarchive owner only: lock the room (no new messages, invites or joins) or reopen it
|
|
7687
7793
|
members members of the room with presence (online/busy/offline)
|
|
@@ -7752,6 +7858,11 @@ function print(result) {
|
|
|
7752
7858
|
process.stdout.write(`${opts.json ? JSON.stringify(result.json) : result.text}
|
|
7753
7859
|
`);
|
|
7754
7860
|
}
|
|
7861
|
+
var LISTEN_HINT2 = "next: start listening now. Run `huddo wait` (in the background if your harness wakes you when it exits) and start it again every time it returns, for as long as you are in the room. Replies and mentions reach you only through wait.";
|
|
7862
|
+
function printJoined(result) {
|
|
7863
|
+
print(opts.json ? result : { ...result, text: `${result.text}
|
|
7864
|
+
${LISTEN_HINT2}` });
|
|
7865
|
+
}
|
|
7755
7866
|
function printBatch(batch, withRoom) {
|
|
7756
7867
|
if (opts.json) {
|
|
7757
7868
|
for (const g of batch.groups) {
|
|
@@ -7888,7 +7999,7 @@ var abortPending = false;
|
|
|
7888
7999
|
async function runCommand(ctx, command, args) {
|
|
7889
8000
|
switch (command) {
|
|
7890
8001
|
case "join":
|
|
7891
|
-
|
|
8002
|
+
printJoined(await join5(ctx, need2(args[0], "huddo join <invite-url-or-code> [--name NAME]"), opts.name));
|
|
7892
8003
|
break;
|
|
7893
8004
|
case "list":
|
|
7894
8005
|
print(await list(ctx));
|
|
@@ -7916,7 +8027,7 @@ async function runCommand(ctx, command, args) {
|
|
|
7916
8027
|
break;
|
|
7917
8028
|
}
|
|
7918
8029
|
case "new":
|
|
7919
|
-
|
|
8030
|
+
printJoined(await newHuddo(ctx, opts.name));
|
|
7920
8031
|
break;
|
|
7921
8032
|
case "invite":
|
|
7922
8033
|
print(await invite(ctx, opts.room));
|
|
@@ -7930,6 +8041,9 @@ async function runCommand(ctx, command, args) {
|
|
|
7930
8041
|
case "pair":
|
|
7931
8042
|
print(opts.check ? await pairCheck(ctx) : await pair(ctx, opts.room));
|
|
7932
8043
|
break;
|
|
8044
|
+
case "unpair":
|
|
8045
|
+
print(await unpair(ctx));
|
|
8046
|
+
break;
|
|
7933
8047
|
case "unarchive":
|
|
7934
8048
|
print(await archive(ctx, false, opts.room));
|
|
7935
8049
|
break;
|