agentchannel 0.8.2 → 0.9.2

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 (61) hide show
  1. package/README.md +152 -50
  2. package/dist/brain.d.ts +78 -0
  3. package/dist/brain.js +271 -0
  4. package/dist/brain.js.map +1 -0
  5. package/dist/cli.js +226 -8
  6. package/dist/cli.js.map +1 -1
  7. package/dist/config.d.ts +15 -0
  8. package/dist/config.js +66 -6
  9. package/dist/config.js.map +1 -1
  10. package/dist/crypto.d.ts +34 -4
  11. package/dist/crypto.js +42 -6
  12. package/dist/crypto.js.map +1 -1
  13. package/dist/distill.d.ts +24 -0
  14. package/dist/distill.js +404 -0
  15. package/dist/distill.js.map +1 -0
  16. package/dist/local-store.d.ts +7 -0
  17. package/dist/local-store.js +54 -0
  18. package/dist/local-store.js.map +1 -0
  19. package/dist/mqtt-client.d.ts +9 -0
  20. package/dist/mqtt-client.js +312 -22
  21. package/dist/mqtt-client.js.map +1 -1
  22. package/dist/server.js +45 -0
  23. package/dist/server.js.map +1 -1
  24. package/dist/store.d.ts +3 -0
  25. package/dist/store.js +16 -2
  26. package/dist/store.js.map +1 -1
  27. package/dist/tools/brain.d.ts +2 -0
  28. package/dist/tools/brain.js +96 -0
  29. package/dist/tools/brain.js.map +1 -0
  30. package/dist/tools/channel.js +6 -6
  31. package/dist/tools/channel.js.map +1 -1
  32. package/dist/tools/get-message.js +1 -1
  33. package/dist/tools/get-message.js.map +1 -1
  34. package/dist/tools/hooks.js +4 -4
  35. package/dist/tools/hooks.js.map +1 -1
  36. package/dist/tools/index.js +8 -0
  37. package/dist/tools/index.js.map +1 -1
  38. package/dist/tools/info.js +3 -1
  39. package/dist/tools/info.js.map +1 -1
  40. package/dist/tools/kick.d.ts +3 -0
  41. package/dist/tools/kick.js +52 -0
  42. package/dist/tools/kick.js.map +1 -0
  43. package/dist/tools/members.js +3 -3
  44. package/dist/tools/members.js.map +1 -1
  45. package/dist/tools/read.js +5 -4
  46. package/dist/tools/read.js.map +1 -1
  47. package/dist/tools/retract.d.ts +3 -0
  48. package/dist/tools/retract.js +27 -0
  49. package/dist/tools/retract.js.map +1 -0
  50. package/dist/tools/update-channel.d.ts +3 -0
  51. package/dist/tools/update-channel.js +50 -0
  52. package/dist/tools/update-channel.js.map +1 -0
  53. package/dist/types.d.ts +23 -1
  54. package/dist/web.d.ts +1 -0
  55. package/dist/web.js +86 -1
  56. package/dist/web.js.map +1 -1
  57. package/package.json +3 -2
  58. package/ui/app.js +518 -89
  59. package/ui/index.html +5 -6
  60. package/ui/style.css +39 -12
  61. package/LICENSE +0 -21
@@ -0,0 +1,54 @@
1
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ const MESSAGES_DIR = join(homedir(), "agentchannel", "messages");
5
+ function channelDir(channel, subchannel) {
6
+ const name = subchannel ? `${channel}.${subchannel}` : channel;
7
+ return join(MESSAGES_DIR, name);
8
+ }
9
+ function monthFile(channel, subchannel, date) {
10
+ const yyyy = date.getFullYear();
11
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
12
+ const name = subchannel ? `${channel}.${subchannel}` : channel;
13
+ return join(channelDir(channel, subchannel), `${name}.${yyyy}-${mm}.jsonl`);
14
+ }
15
+ export class LocalStore {
16
+ written = new Set(); // dedup by msg id
17
+ appendMessage(msg) {
18
+ if (this.written.has(msg.id))
19
+ return;
20
+ const dir = channelDir(msg.channel, msg.subchannel);
21
+ if (!existsSync(dir))
22
+ mkdirSync(dir, { recursive: true });
23
+ const file = monthFile(msg.channel, msg.subchannel, new Date(msg.timestamp));
24
+ appendFileSync(file, JSON.stringify(msg) + "\n");
25
+ this.written.add(msg.id);
26
+ }
27
+ readMessages(channel, subchannel, since, limit) {
28
+ const dir = channelDir(channel, subchannel);
29
+ if (!existsSync(dir))
30
+ return [];
31
+ const files = readdirSync(dir)
32
+ .filter((f) => f.endsWith(".jsonl"))
33
+ .sort(); // chronological by YYYY-MM
34
+ const msgs = [];
35
+ for (const file of files) {
36
+ const lines = readFileSync(join(dir, file), "utf8").split("\n").filter(Boolean);
37
+ for (const line of lines) {
38
+ try {
39
+ const msg = JSON.parse(line);
40
+ if (since && msg.timestamp <= since)
41
+ continue;
42
+ msgs.push(msg);
43
+ }
44
+ catch { }
45
+ }
46
+ }
47
+ msgs.sort((a, b) => a.timestamp - b.timestamp);
48
+ return limit ? msgs.slice(-limit) : msgs;
49
+ }
50
+ hasMessage(id) {
51
+ return this.written.has(id);
52
+ }
53
+ }
54
+ //# sourceMappingURL=local-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"local-store.js","sourceRoot":"","sources":["../src/local-store.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { MessageStore } from "./store.js";
2
+ import { LocalStore } from "./local-store.js";
2
3
  import type { ChatConfig, SingleChannelConfig, Message, ChannelMeta } from "./types.js";
3
4
  export declare class AgentChatClient {
4
5
  private client;
@@ -7,6 +8,7 @@ export declare class AgentChatClient {
7
8
  private name;
8
9
  private broker;
9
10
  readonly store: MessageStore;
11
+ readonly localStore: LocalStore;
10
12
  private onMessage?;
11
13
  private onMeta?;
12
14
  private channelMeta;
@@ -46,6 +48,13 @@ export declare class AgentChatClient {
46
48
  setOnMeta(handler: (channel: string, meta: ChannelMeta) => void): void;
47
49
  getMeta(channel: string): ChannelMeta | undefined;
48
50
  publishMeta(channelName: string, meta: ChannelMeta): Promise<void>;
51
+ private broadcastEpochBump;
52
+ removeMember(channelName: string, targetFingerprint: string, opts?: {
53
+ silent?: boolean;
54
+ reason?: string;
55
+ }): Promise<void>;
56
+ rotateChannel(channelName: string): Promise<void>;
57
+ retractMessage(messageId: string, channelName?: string, reason?: string): Promise<Message>;
49
58
  joinDm(theirFingerprint: string): Promise<string>;
50
59
  sendDm(theirFingerprint: string, content: string, opts?: {
51
60
  subject?: string;
@@ -4,10 +4,12 @@ import { userInfo } from "node:os";
4
4
  import { execSync } from "node:child_process";
5
5
  import { deriveKey, deriveSubKey, hashRoom, hashSub, deriveDmKey, hashDm, encrypt, decrypt } from "./crypto.js";
6
6
  import { MessageStore } from "./store.js";
7
+ import { LocalStore } from "./local-store.js";
7
8
  import { storeMessage, fetchHistory, registerMember } from "./persistence.js";
8
9
  import { ensureIdentity, signMessage, verifySignature, getFingerprint } from "./identity.js";
9
10
  import { checkTrust } from "./trust-store.js";
10
- const DEFAULT_BROKER = "mqtt://broker.emqx.io:1883";
11
+ import { getSyncEnabled, updateChannelEpoch, getChannelEpoch, removeChannel } from "./config.js";
12
+ const DEFAULT_BROKER = "mqtt://35.238.24.59:1883";
11
13
  function getDefaultName() {
12
14
  // 1. OS username
13
15
  try {
@@ -28,7 +30,7 @@ function getDefaultName() {
28
30
  return `agent-${fp}`;
29
31
  }
30
32
  function displayName(state) {
31
- return state.subchannel ? `##${state.subchannel}` : `#${state.channel}`;
33
+ return state.subchannel ? `#${state.channel}/${state.subchannel}` : `#${state.channel}`;
32
34
  }
33
35
  function fullId(state) {
34
36
  return state.subchannel ? `${state.channel}/${state.subchannel}` : state.channel;
@@ -40,6 +42,7 @@ export class AgentChatClient {
40
42
  name;
41
43
  broker;
42
44
  store;
45
+ localStore;
43
46
  onMessage;
44
47
  onMeta;
45
48
  channelMeta = new Map();
@@ -49,12 +52,15 @@ export class AgentChatClient {
49
52
  this.name = config.name || getDefaultName();
50
53
  this.broker = config.broker || DEFAULT_BROKER;
51
54
  this.store = new MessageStore();
55
+ this.localStore = new LocalStore();
52
56
  this.identity = ensureIdentity();
53
57
  this.silent = config.silent || false;
54
58
  for (const ch of config.channels) {
55
- const key = ch.subchannel ? deriveSubKey(ch.key, ch.subchannel) : deriveKey(ch.key);
59
+ const epoch = ch.epoch ?? 0;
60
+ const key = ch.subchannel ? deriveSubKey(ch.key, ch.subchannel, epoch) : deriveKey(ch.key, epoch);
56
61
  const hash = ch.subchannel ? hashSub(ch.key, ch.subchannel) : hashRoom(ch.key);
57
- this.channels.set(hash, { channel: ch.channel, subchannel: ch.subchannel, key, hash });
62
+ const channelHash = ch.channelHash || hash;
63
+ this.channels.set(hash, { channel: ch.channel, subchannel: ch.subchannel, key, hash, channelHash });
58
64
  if (!ch.subchannel)
59
65
  this.channelKeys.set(ch.channel, ch.key);
60
66
  }
@@ -118,7 +124,7 @@ export class AgentChatClient {
118
124
  this.client.subscribe(topics, { qos: 1 });
119
125
  // Always register as member (even in silent mode)
120
126
  for (const [_hash, state] of this.channels) {
121
- registerMember(state.hash, this.identity.fingerprint, this.name);
127
+ registerMember(state.channelHash, this.identity.fingerprint, this.name);
122
128
  }
123
129
  // Only announce presence for long-lived connections (not silent)
124
130
  if (!this.silent) {
@@ -171,6 +177,102 @@ export class AgentChatClient {
171
177
  else {
172
178
  msg.trustLevel = "unsigned";
173
179
  }
180
+ // Handle retraction messages — self-only, verify signature
181
+ if (msg.type === "retraction") {
182
+ try {
183
+ const retraction = JSON.parse(msg.content);
184
+ // Verify: retraction sender must match original message sender
185
+ const target = this.store.getMessageById(retraction.target_id);
186
+ if (target && target.senderKey && msg.senderKey === target.senderKey) {
187
+ this.store.addRetraction(retraction.target_id);
188
+ // Surface retraction as a system message
189
+ const sysMsg = {
190
+ id: msg.id,
191
+ channel: state.channel,
192
+ subchannel: state.subchannel,
193
+ sender: "system",
194
+ content: `@${msg.sender} retracted message ${retraction.target_id}`,
195
+ timestamp: Date.now(),
196
+ type: "system",
197
+ };
198
+ this.store.addMessage(sysMsg);
199
+ if (this.onMessage)
200
+ this.onMessage(sysMsg);
201
+ }
202
+ }
203
+ catch { }
204
+ return;
205
+ }
206
+ // Handle epoch_bump DM — owner rotated channel key
207
+ if (msg.type === "epoch_bump") {
208
+ try {
209
+ const bump = JSON.parse(msg.content);
210
+ // Verify sender is a channel owner
211
+ const meta = this.channelMeta.get(bump.channel);
212
+ if (meta && msg.senderKey && meta.owners.includes(msg.senderKey)) {
213
+ // Apply epoch rotation: derive new key + hash, resubscribe
214
+ const newKey = deriveKey(bump.new_seed, bump.new_epoch);
215
+ const newHash = hashRoom(bump.new_seed);
216
+ // Unsubscribe from old topic, preserve channelHash
217
+ let preservedChannelHash = "";
218
+ for (const [hash, s] of this.channels) {
219
+ if (s.channel === bump.channel && !s.subchannel) {
220
+ preservedChannelHash = s.channelHash;
221
+ if (this.client) {
222
+ this.client.unsubscribe([this.msgTopic(hash), this.presTopic(hash)]);
223
+ }
224
+ this.channels.delete(hash);
225
+ break;
226
+ }
227
+ }
228
+ // Subscribe to new topic (channelHash stays the same)
229
+ this.channels.set(newHash, { channel: bump.channel, key: newKey, hash: newHash, channelHash: preservedChannelHash });
230
+ this.channelKeys.set(bump.channel, bump.new_seed);
231
+ if (this.client) {
232
+ this.client.subscribe([this.msgTopic(newHash), this.presTopic(newHash)], { qos: 1 });
233
+ }
234
+ // Update config
235
+ updateChannelEpoch(bump.channel, bump.new_seed, bump.new_epoch);
236
+ // System message
237
+ const removalInfo = bump.removed_fps?.length
238
+ ? `Members removed: ${bump.removed_fps.join(", ")}`
239
+ : "Manual key rotation";
240
+ const sysMsg = {
241
+ id: randomBytes(4).toString("hex"),
242
+ channel: bump.channel,
243
+ sender: "system",
244
+ content: `Channel key rotated to epoch ${bump.new_epoch}. ${removalInfo}`,
245
+ timestamp: Date.now(),
246
+ type: "system",
247
+ };
248
+ this.store.addMessage(sysMsg);
249
+ if (this.onMessage)
250
+ this.onMessage(sysMsg);
251
+ }
252
+ }
253
+ catch { }
254
+ return;
255
+ }
256
+ // Handle removal_notice DM — you were removed from a channel
257
+ if (msg.type === "removal_notice") {
258
+ try {
259
+ const notice = JSON.parse(msg.content);
260
+ const reason = notice.reason ? ` Reason: ${notice.reason}` : "";
261
+ const sysMsg = {
262
+ id: randomBytes(4).toString("hex"),
263
+ channel: notice.channel,
264
+ sender: "system",
265
+ content: `You were removed from #${notice.channel} by ${notice.removed_by}.${reason}`,
266
+ timestamp: Date.now(),
267
+ type: "system",
268
+ };
269
+ this.store.addMessage(sysMsg);
270
+ if (this.onMessage)
271
+ this.onMessage(sysMsg);
272
+ }
273
+ catch { }
274
+ return;
275
+ }
174
276
  // Handle channel_meta messages — only accept from owner
175
277
  if (msg.type === "channel_meta") {
176
278
  try {
@@ -179,18 +281,28 @@ export class AgentChatClient {
179
281
  // First meta (no existing) or sender is one of the owners
180
282
  if (!existing || (msg.senderKey && existing.owners.includes(msg.senderKey))) {
181
283
  this.channelMeta.set(state.channel, meta);
182
- // Auto-join subchannels listed in meta
183
- if (meta.subchannels && meta.subchannels.length > 0) {
184
- const parentConfig = Array.from(this.channels.values()).find((s) => s.channel === state.channel && !s.subchannel);
185
- if (parentConfig) {
186
- for (const sub of meta.subchannels) {
187
- const subKey = deriveSubKey(this.getChannelKeyString(state.channel), sub);
188
- const subHash = hashSub(this.getChannelKeyString(state.channel), sub);
189
- if (!this.channels.has(subHash)) {
190
- this.channels.set(subHash, { channel: state.channel, subchannel: sub, key: subKey, hash: subHash });
191
- if (this.client) {
192
- this.client.subscribe([this.msgTopic(subHash), this.presTopic(subHash)], { qos: 1 });
193
- }
284
+ // Sync subchannels: join new ones, leave removed ones
285
+ const rawKey = this.getChannelKeyString(state.channel);
286
+ if (rawKey) {
287
+ const metaSubs = new Set(meta.subchannels || []);
288
+ // Leave subchannels no longer in meta
289
+ for (const [hash, s] of this.channels) {
290
+ if (s.channel === state.channel && s.subchannel && !metaSubs.has(s.subchannel)) {
291
+ if (this.client) {
292
+ this.client.unsubscribe([this.msgTopic(hash), this.presTopic(hash)]);
293
+ }
294
+ this.channels.delete(hash);
295
+ removeChannel(state.channel, s.subchannel);
296
+ }
297
+ }
298
+ // Join new subchannels
299
+ for (const sub of metaSubs) {
300
+ const subKey = deriveSubKey(rawKey, sub);
301
+ const subHash = hashSub(rawKey, sub);
302
+ if (!this.channels.has(subHash)) {
303
+ this.channels.set(subHash, { channel: state.channel, subchannel: sub, key: subKey, hash: subHash, channelHash: subHash });
304
+ if (this.client) {
305
+ this.client.subscribe([this.msgTopic(subHash), this.presTopic(subHash)], { qos: 1 });
194
306
  }
195
307
  }
196
308
  }
@@ -206,7 +318,14 @@ export class AgentChatClient {
206
318
  this.store.updateMember(msg.sender, state.channel, state.subchannel, msg.senderKey);
207
319
  // Persist to cloud (best-effort)
208
320
  if (msg.type === "chat") {
209
- storeMessage(msg.id, state.hash, raw, msg.timestamp);
321
+ storeMessage(msg.id, state.channelHash, raw, msg.timestamp);
322
+ }
323
+ // Persist locally (if sync enabled)
324
+ if (msg.type === "chat" && getSyncEnabled(state.channel, state.subchannel)) {
325
+ try {
326
+ this.localStore.appendMessage(msg);
327
+ }
328
+ catch { }
210
329
  }
211
330
  if (this.onMessage) {
212
331
  this.onMessage(msg);
@@ -229,7 +348,7 @@ export class AgentChatClient {
229
348
  // Skip own presence
230
349
  if (data.name === this.name)
231
350
  return;
232
- const label = subchannel ? `##${subchannel}` : `#${channel}`;
351
+ const label = subchannel ? `#${channel}/${subchannel}` : `#${channel}`;
233
352
  if (data.status === "online") {
234
353
  this.store.updateMember(data.name, channel, subchannel);
235
354
  const sysMsg = {
@@ -276,6 +395,11 @@ export class AgentChatClient {
276
395
  else {
277
396
  target = this.channels.values().next().value;
278
397
  }
398
+ // Announcement mode: only owners can send
399
+ const meta = this.channelMeta.get(target.channel);
400
+ if (meta?.mode === "announcement" && !meta.owners.includes(this.identity.fingerprint)) {
401
+ throw new Error(`#${target.channel} is an announcement channel — only owners can send messages`);
402
+ }
279
403
  const msg = {
280
404
  id: randomBytes(8).toString("hex"),
281
405
  channel: target.channel,
@@ -306,9 +430,10 @@ export class AgentChatClient {
306
430
  throw new Error("Not connected");
307
431
  const key = config.subchannel ? deriveSubKey(config.key, config.subchannel) : deriveKey(config.key);
308
432
  const hash = config.subchannel ? hashSub(config.key, config.subchannel) : hashRoom(config.key);
433
+ const channelHash = hash;
309
434
  if (this.channels.has(hash))
310
435
  return;
311
- this.channels.set(hash, { channel: config.channel, subchannel: config.subchannel, key, hash });
436
+ this.channels.set(hash, { channel: config.channel, subchannel: config.subchannel, key, hash, channelHash });
312
437
  if (!config.subchannel)
313
438
  this.channelKeys.set(config.channel, config.key);
314
439
  this.client.subscribe([this.msgTopic(hash), this.presTopic(hash)], { qos: 1 });
@@ -366,6 +491,13 @@ export class AgentChatClient {
366
491
  continue;
367
492
  }
368
493
  this.store.addMessage(msg);
494
+ // Backfill local store from history
495
+ if (msg.type === "chat" && getSyncEnabled(state.channel, state.subchannel)) {
496
+ try {
497
+ this.localStore.appendMessage(msg);
498
+ }
499
+ catch { }
500
+ }
369
501
  }
370
502
  catch {
371
503
  // Can't decrypt — wrong key or corrupted
@@ -419,13 +551,171 @@ export class AgentChatClient {
419
551
  if (err)
420
552
  reject(err);
421
553
  else {
422
- storeMessage(msg.id, target.hash, raw, msg.timestamp);
554
+ storeMessage(msg.id, target.channelHash, raw, msg.timestamp);
423
555
  this.channelMeta.set(channelName, meta);
424
556
  resolve();
425
557
  }
426
558
  });
427
559
  });
428
560
  }
561
+ // ── Kick / Epoch Rotation ───────────────────────────
562
+ // Shared: broadcast epoch_bump DM to members + apply rotation locally
563
+ async broadcastEpochBump(channelName, recipients, bump) {
564
+ if (!this.client)
565
+ throw new Error("Not connected");
566
+ // DM epoch_bump to each recipient
567
+ for (const member of recipients) {
568
+ if (!member.fingerprint || member.fingerprint === this.identity.fingerprint)
569
+ continue;
570
+ const dmMsg = {
571
+ id: randomBytes(8).toString("hex"),
572
+ channel: `dm:${[this.identity.fingerprint, member.fingerprint].sort().join(":")}`,
573
+ sender: this.name,
574
+ content: JSON.stringify(bump),
575
+ timestamp: Date.now(),
576
+ type: "epoch_bump",
577
+ senderKey: this.identity.fingerprint,
578
+ };
579
+ const dataToSign = JSON.stringify(dmMsg);
580
+ dmMsg.signature = signMessage(dataToSign, this.identity.privateKeyPem);
581
+ const key = deriveDmKey(this.identity.fingerprint, member.fingerprint);
582
+ const hash = hashDm(this.identity.fingerprint, member.fingerprint);
583
+ const encrypted = encrypt(JSON.stringify(dmMsg), key);
584
+ this.client.publish(this.msgTopic(hash), JSON.stringify(encrypted), { qos: 1 });
585
+ }
586
+ // Apply rotation locally (channelHash preserved — D1 key never changes)
587
+ const newKey = deriveKey(bump.new_seed, bump.new_epoch);
588
+ const newHash = hashRoom(bump.new_seed);
589
+ let preservedChannelHash = "";
590
+ for (const [hash, s] of this.channels) {
591
+ if (s.channel === channelName && !s.subchannel) {
592
+ preservedChannelHash = s.channelHash;
593
+ this.client.unsubscribe([this.msgTopic(hash), this.presTopic(hash)]);
594
+ this.channels.delete(hash);
595
+ break;
596
+ }
597
+ }
598
+ this.channels.set(newHash, { channel: channelName, key: newKey, hash: newHash, channelHash: preservedChannelHash });
599
+ this.channelKeys.set(channelName, bump.new_seed);
600
+ this.client.subscribe([this.msgTopic(newHash), this.presTopic(newHash)], { qos: 1 });
601
+ updateChannelEpoch(channelName, bump.new_seed, bump.new_epoch);
602
+ }
603
+ async removeMember(channelName, targetFingerprint, opts) {
604
+ if (!this.client)
605
+ throw new Error("Not connected");
606
+ const meta = this.channelMeta.get(channelName);
607
+ if (!meta || !meta.owners.includes(this.identity.fingerprint)) {
608
+ throw new Error("Only channel owners can remove members");
609
+ }
610
+ const newSeed = randomBytes(32).toString("base64");
611
+ const newEpoch = getChannelEpoch(channelName) + 1;
612
+ const members = this.store.getMembers(channelName);
613
+ const remaining = members.filter((m) => m.fingerprint && m.fingerprint !== targetFingerprint);
614
+ await this.broadcastEpochBump(channelName, remaining, {
615
+ channel: channelName,
616
+ new_seed: newSeed,
617
+ new_epoch: newEpoch,
618
+ removed_fps: [targetFingerprint],
619
+ });
620
+ // Send removal_notice DM (unless --silent)
621
+ if (!opts?.silent) {
622
+ const noticePayload = {
623
+ channel: channelName,
624
+ removed_at: Date.now(),
625
+ removed_by: this.identity.fingerprint,
626
+ reason: opts?.reason,
627
+ };
628
+ const noticeMsg = {
629
+ id: randomBytes(8).toString("hex"),
630
+ channel: `dm:${[this.identity.fingerprint, targetFingerprint].sort().join(":")}`,
631
+ sender: this.name,
632
+ content: JSON.stringify(noticePayload),
633
+ timestamp: Date.now(),
634
+ type: "removal_notice",
635
+ senderKey: this.identity.fingerprint,
636
+ };
637
+ const dataToSign = JSON.stringify(noticeMsg);
638
+ noticeMsg.signature = signMessage(dataToSign, this.identity.privateKeyPem);
639
+ const key = deriveDmKey(this.identity.fingerprint, targetFingerprint);
640
+ const hash = hashDm(this.identity.fingerprint, targetFingerprint);
641
+ const encrypted = encrypt(JSON.stringify(noticeMsg), key);
642
+ this.client.publish(this.msgTopic(hash), JSON.stringify(encrypted), { qos: 1 });
643
+ }
644
+ }
645
+ async rotateChannel(channelName) {
646
+ if (!this.client)
647
+ throw new Error("Not connected");
648
+ const meta = this.channelMeta.get(channelName);
649
+ if (!meta || !meta.owners.includes(this.identity.fingerprint)) {
650
+ throw new Error("Only channel owners can rotate channel keys");
651
+ }
652
+ const newSeed = randomBytes(32).toString("base64");
653
+ const newEpoch = getChannelEpoch(channelName) + 1;
654
+ const members = this.store.getMembers(channelName);
655
+ await this.broadcastEpochBump(channelName, members, {
656
+ channel: channelName,
657
+ new_seed: newSeed,
658
+ new_epoch: newEpoch,
659
+ });
660
+ }
661
+ // ── Retraction ──────────────────────────────────────
662
+ async retractMessage(messageId, channelName, reason) {
663
+ if (!this.client)
664
+ throw new Error("Not connected");
665
+ // Find the target message
666
+ const target = this.store.getMessageById(messageId);
667
+ if (!target)
668
+ throw new Error(`Message "${messageId}" not found`);
669
+ // Verify ownership
670
+ if (target.senderKey !== this.identity.fingerprint) {
671
+ throw new Error("You can only retract your own messages");
672
+ }
673
+ // Verify 24h window
674
+ const age = Date.now() - target.timestamp;
675
+ if (age > 24 * 60 * 60 * 1000) {
676
+ throw new Error("Retraction window expired (24h limit)");
677
+ }
678
+ const retraction = {
679
+ target_id: messageId,
680
+ retracted_at: Date.now(),
681
+ reason,
682
+ };
683
+ const ch = channelName || target.channel;
684
+ const targetChannel = target.subchannel ? `${target.channel}/${target.subchannel}` : target.channel;
685
+ const msg = {
686
+ id: randomBytes(8).toString("hex"),
687
+ channel: target.channel,
688
+ subchannel: target.subchannel,
689
+ sender: this.name,
690
+ content: JSON.stringify(retraction),
691
+ timestamp: Date.now(),
692
+ type: "retraction",
693
+ senderKey: this.identity.fingerprint,
694
+ };
695
+ const dataToSign = JSON.stringify(msg);
696
+ msg.signature = signMessage(dataToSign, this.identity.privateKeyPem);
697
+ // Find the channel state for encryption
698
+ let targetState;
699
+ for (const state of this.channels.values()) {
700
+ if (fullId(state) === targetChannel || state.channel === target.channel) {
701
+ targetState = state;
702
+ break;
703
+ }
704
+ }
705
+ if (!targetState)
706
+ throw new Error(`Channel not found for retraction`);
707
+ const encrypted = encrypt(JSON.stringify(msg), targetState.key);
708
+ return new Promise((resolve, reject) => {
709
+ this.client.publish(this.msgTopic(targetState.hash), JSON.stringify(encrypted), { qos: 1 }, (err) => {
710
+ if (err)
711
+ reject(err);
712
+ else {
713
+ this.store.addRetraction(messageId);
714
+ resolve(msg);
715
+ }
716
+ });
717
+ });
718
+ }
429
719
  // ── DM (Direct Message) support ──────────────────────
430
720
  async joinDm(theirFingerprint) {
431
721
  if (!this.client)
@@ -435,7 +725,7 @@ export class AgentChatClient {
435
725
  const hash = hashDm(myFp, theirFingerprint);
436
726
  const dmChannel = `dm:${[myFp, theirFingerprint].sort().join(":")}`;
437
727
  if (!this.channels.has(hash)) {
438
- this.channels.set(hash, { channel: dmChannel, key, hash });
728
+ this.channels.set(hash, { channel: dmChannel, key, hash, channelHash: hash });
439
729
  this.client.subscribe([this.msgTopic(hash)], { qos: 1 });
440
730
  // Load DM history
441
731
  try {