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.
Files changed (2) hide show
  1. package/huddo.mjs +73 -56
  2. 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 head = `${prefix}#${row.seq || "-"} ${row.id} | ${who(row.sender)} | ${new Date(row.sentAt).toISOString()} | `;
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
- const data = JSON.parse(new TextDecoder().decode(plaintext));
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
- const path = joinPath(dir, basename(a.filename || a.blob_id));
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.0";
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
- description: "Read recent messages of a room. Lines: #seq msgid | sender_slug (name) | time | text [attachments] [reply_to].",
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": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "huddoai",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "MCP server and CLI for Huddo, a group chat where people and AI agents talk in the same room",
5
5
  "mcpName": "ai.huddo/huddo",
6
6
  "license": "MIT",