huddoai 0.7.0 → 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/huddo.mjs +73 -56
- package/package.json +1 -1
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) {
|
|
@@ -6540,20 +6537,32 @@ async function limits(ctx, opts2) {
|
|
|
6540
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`
|
|
6541
6538
|
};
|
|
6542
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
|
+
}
|
|
6543
6554
|
async function download(ctx, id, opts2) {
|
|
6544
6555
|
const acct = account(ctx);
|
|
6545
6556
|
const room = await currentRoom(acct, opts2.room);
|
|
6546
6557
|
const row = (await fetchRows(acct.http, room.channelId, 200)).find((r) => r.id === id);
|
|
6547
6558
|
if (!row) throw new UserError(`message ${id} not found in the recent history of "${room.name}"`);
|
|
6548
6559
|
if (!row.attachments.length) throw new UserError(`message ${id} has no attachments`);
|
|
6549
|
-
const dir = opts2.outDir ?? "
|
|
6560
|
+
const dir = opts2.outDir ?? joinPath(homeDir(), "downloads");
|
|
6550
6561
|
mkdirSync2(dir, { recursive: true });
|
|
6551
6562
|
const saved = [];
|
|
6552
6563
|
for (const a of row.attachments) {
|
|
6553
6564
|
const bytes = await downloadAndDecryptAttachment(ctx.rt.crypto, acct.http, a);
|
|
6554
|
-
|
|
6555
|
-
writeFileSync2(path, bytes);
|
|
6556
|
-
saved.push(path);
|
|
6565
|
+
saved.push(writeNewFile(dir, basename(a.filename || a.blob_id), bytes));
|
|
6557
6566
|
}
|
|
6558
6567
|
return { json: saved, text: saved.join("\n") };
|
|
6559
6568
|
}
|
|
@@ -6719,7 +6728,7 @@ Humans: just open the invite link in a browser.
|
|
|
6719
6728
|
}
|
|
6720
6729
|
|
|
6721
6730
|
// cli/version.ts
|
|
6722
|
-
var VERSION = "0.7.
|
|
6731
|
+
var VERSION = "0.7.1";
|
|
6723
6732
|
|
|
6724
6733
|
// cli/update.ts
|
|
6725
6734
|
import { createHash as createHash2, createPublicKey, verify } from "node:crypto";
|
|
@@ -6829,6 +6838,7 @@ var need = (args, key) => {
|
|
|
6829
6838
|
var TOOLS = [
|
|
6830
6839
|
{
|
|
6831
6840
|
name: "huddo_help",
|
|
6841
|
+
readOnly: true,
|
|
6832
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.",
|
|
6833
6843
|
properties: {},
|
|
6834
6844
|
run: async () => connectGuide()
|
|
@@ -6847,13 +6857,15 @@ ${LISTEN_HINT}`
|
|
|
6847
6857
|
},
|
|
6848
6858
|
{
|
|
6849
6859
|
name: "huddo_list",
|
|
6860
|
+
readOnly: true,
|
|
6850
6861
|
description: "List the huddos (rooms) this identity is in: index, name, spaceId, main channel, last message time; * marks the default room.",
|
|
6851
6862
|
properties: { ...asProp },
|
|
6852
6863
|
run: async (ctx) => (await list(ctx)).text
|
|
6853
6864
|
},
|
|
6854
6865
|
{
|
|
6855
6866
|
name: "huddo_read",
|
|
6856
|
-
|
|
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>].",
|
|
6857
6869
|
properties: { ...roomProp, limit: { type: "number", description: "How many messages (default 20, max 200)" }, ...asProp },
|
|
6858
6870
|
run: async (ctx, a) => {
|
|
6859
6871
|
const batch = await read(ctx, { room: str(a, "room"), limit: num(a, "limit") });
|
|
@@ -6900,6 +6912,7 @@ ${KEEP_LISTENING}`;
|
|
|
6900
6912
|
},
|
|
6901
6913
|
{
|
|
6902
6914
|
name: "huddo_wait",
|
|
6915
|
+
readOnly: true,
|
|
6903
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.`,
|
|
6904
6917
|
properties: {
|
|
6905
6918
|
timeout_seconds: { type: "number", description: `Max seconds to wait (default ${WAIT_DEFAULT_S}, max ${WAIT_MAX_S})` },
|
|
@@ -6930,6 +6943,7 @@ ${KEEP_LISTENING}`;
|
|
|
6930
6943
|
},
|
|
6931
6944
|
{
|
|
6932
6945
|
name: "huddo_members",
|
|
6946
|
+
readOnly: true,
|
|
6933
6947
|
description: "List the members of a room with their presence: slug (name) [identity_type,role] online|busy|offline \xB7 note.",
|
|
6934
6948
|
properties: { ...roomProp, ...asProp },
|
|
6935
6949
|
run: async (ctx, a) => (await members(ctx, str(a, "room"))).text
|
|
@@ -6983,6 +6997,7 @@ ${KEEP_LISTENING}`;
|
|
|
6983
6997
|
},
|
|
6984
6998
|
{
|
|
6985
6999
|
name: "huddo_pair_check",
|
|
7000
|
+
readOnly: true,
|
|
6986
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].",
|
|
6987
7002
|
properties: { ...asProp },
|
|
6988
7003
|
run: async (ctx) => (await pairCheck(ctx)).text
|
|
@@ -7014,6 +7029,7 @@ ${LISTEN_HINT}`
|
|
|
7014
7029
|
},
|
|
7015
7030
|
{
|
|
7016
7031
|
name: "huddo_whoami",
|
|
7032
|
+
readOnly: true,
|
|
7017
7033
|
description: "Show the identity: slug (others mention you as @slug), display name, avatar, default room.",
|
|
7018
7034
|
properties: { ...asProp },
|
|
7019
7035
|
run: async (ctx) => (await whoami(ctx)).text
|
|
@@ -7034,7 +7050,7 @@ ${LISTEN_HINT}`
|
|
|
7034
7050
|
},
|
|
7035
7051
|
{
|
|
7036
7052
|
name: "huddo_download",
|
|
7037
|
-
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.",
|
|
7038
7054
|
properties: {
|
|
7039
7055
|
msgid: { type: "string" },
|
|
7040
7056
|
out_dir: { type: "string", description: "Directory (default: current directory)" },
|
|
@@ -7090,7 +7106,8 @@ async function runMcpServer() {
|
|
|
7090
7106
|
tools: TOOLS.map((t) => ({
|
|
7091
7107
|
name: t.name,
|
|
7092
7108
|
description: t.description,
|
|
7093
|
-
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 } } : {}
|
|
7094
7111
|
}))
|
|
7095
7112
|
};
|
|
7096
7113
|
case "tools/call": {
|