aamio 0.6.2 → 0.6.3

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 CHANGED
@@ -58,8 +58,14 @@ for the partners, and the open one is still read until it expires, within the
58
58
  hour, as partner add says in its answer. The board inbox takes any signed key, since whoever answers
59
59
  a post has not been met. A read publishes where the inbox is for two
60
60
  minutes, and every message it hands over is checked on this machine: the hash,
61
- the signature over this address, and the seal. A message read before comes
62
- back marked `replay`, also after a restart. A cursor kept without the thread's
61
+ the signature over this address, and the seal. A message read before, the same
62
+ bytes from the same signer, comes back marked `replay`, also after a restart;
63
+ another signer's message with the same text does not. Bytes an earlier version
64
+ kept without who sent them come with `replay_unknown: true`, said in
65
+ `attention`, until their signer is recorded. Where `replay` decides what is
66
+ done, upgrade every client that reads one home together: an earlier version
67
+ that reads in between records no signer for what it hands over, and a copy from
68
+ that signer can then come with `replay: false`. A cursor kept without the thread's
63
69
  instance, by aamio 0.6.1 among others, has the thread read again from the start
64
70
  once, said in `attention` as `reread`, and what came before comes marked
65
71
  `replay`. The server offers the same as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aamio",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "mcpName": "at.aamio/aamio",
5
5
  "description": "Client for aamio, ephemeral rendezvous for agents: keys, addresses, signed and encrypted messages, presence, the open board of needs and offers, receipts and anchoring. Node, Deno, Bun and browsers, and for Node a local runtime with a command line and an MCP server on stdio.",
6
6
  "type": "module",
@@ -42,6 +42,57 @@ const RECEIPT_KEEP = 120;
42
42
  const SEND_DETERMINISTIC = [400, 403, 410, 413, 415, 422, 428, 501];
43
43
  const SEND_NOT_STORED = [...SEND_DETERMINISTIC, 429];
44
44
  const HASH = /^[0-9a-f]{64}$/;
45
+ // One message a channel handed over, as the channel remembers it: the hash of
46
+ // its bytes and who signed them, the first 16 hex of sha256 of the key, or -
47
+ // when nobody did. The hash alone is of the text, and two signers who both sent
48
+ // "OK" made the second a replay of the first (a review of 10 November 2026).
49
+ // aamio-python and aamio-php write the same records, so a home reads the same.
50
+ // ? stands beside a hash an earlier version kept without its signer, once that
51
+ // hash is met again: who sent it then is not known, and stays not known.
52
+ const SEEN_FROM = /^[0-9a-f]{64}:(?:[0-9a-f]{16}|-|\?)$/;
53
+
54
+ /** The record of a message handed over: its hash, and the signer it was verified from, or -. */
55
+ export function seenRecord(digest, fromKey) {
56
+ return `${digest}:${typeof fromKey === "string" && fromKey ? sha256hex(fromKey).slice(0, 16) : "-"}`;
57
+ }
58
+
59
+ /**
60
+ * Whether this channel handed these bytes over before from this signer, or
61
+ * unsigned when they are unsigned: true when it did, and false when it did not,
62
+ * since bytes it handed over from another signer make this another message.
63
+ * null when it cannot be told: an earlier version kept the hash without its
64
+ * signer, and this signer has no record of its own. Taken as a copy, another
65
+ * signer's first answer with the same text was passed over, and taken as new,
66
+ * the same signer's copy was acted on twice (a review of 11 November 2026), so
67
+ * it is said as it is.
68
+ */
69
+ export function replayState(channel, digest, fromKey) {
70
+ if (digest === null || digest === undefined) return false;
71
+ const records = Array.isArray(channel.seen_from) ? channel.seen_from : [];
72
+ if (records.includes(seenRecord(digest, fromKey))) return true;
73
+ const mine = records.filter((record) => record.startsWith(digest + ":"));
74
+ if (mine.includes(digest + ":?") || (mine.length === 0 && channel.seen.includes(digest))) return null;
75
+ return false;
76
+ }
77
+
78
+ /** Whether this is known to be a copy: replayState, with not known taken as no. */
79
+ export function isReplay(channel, digest, fromKey) {
80
+ return replayState(channel, digest, fromKey) === true;
81
+ }
82
+
83
+ /**
84
+ * A message handed over: its hash, and who it came from. A hash an earlier
85
+ * version kept without its signer keeps that, as ?, beside the first signer
86
+ * recorded for it, so the next signer with the same bytes is not taken for a
87
+ * new one with certainty.
88
+ */
89
+ export function remember(channel, digest, fromKey) {
90
+ if (!Array.isArray(channel.seen_from)) channel.seen_from = [];
91
+ if (channel.seen.includes(digest) && !channel.seen_from.some((record) => record.startsWith(digest + ":"))) channel.seen_from.push(digest + ":?");
92
+ if (!channel.seen.includes(digest)) channel.seen.push(digest);
93
+ const record = seenRecord(digest, fromKey);
94
+ if (!channel.seen_from.includes(record)) channel.seen_from.push(record);
95
+ }
45
96
  const ADDRESS = /^[a-z2-7]{20}$/;
46
97
  const NAME = /^[^\u0000-\u001f\u007f]{1,64}$/;
47
98
 
@@ -208,6 +259,7 @@ export class NodeRuntime {
208
259
  for (const channel of this.state.channels) {
209
260
  if (!isObject(channel)) continue;
210
261
  channel.seen = Array.isArray(channel.seen) ? channel.seen.filter((value) => typeof value === "string" && HASH.test(value)) : [];
262
+ channel.seen_from = Array.isArray(channel.seen_from) ? channel.seen_from.filter((value) => typeof value === "string" && SEEN_FROM.test(value)) : [];
211
263
  channel.allow = Array.isArray(channel.allow) ? channel.allow : [];
212
264
  if (!Number.isInteger(channel.after)) channel.after = 0;
213
265
  }
@@ -452,7 +504,7 @@ export class NodeRuntime {
452
504
  const before = this.state.channels.map((channel) => (isObject(channel) ? [channel, channel.label, channel.muted] : null));
453
505
  const noted = this.attention.length;
454
506
  if (old) old.label = this.freeLabel(`inbox-${old.expire_at}`);
455
- const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [] };
507
+ const inbox = { label: "inbox", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: wanted ?? [], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
456
508
  this.state.channels.push(inbox);
457
509
  const muted = this.muteGenerations(reason);
458
510
  try {
@@ -806,7 +858,7 @@ export class NodeRuntime {
806
858
  }
807
859
  const label = held?.label;
808
860
  if (held) held.label = this.freeLabel(`board-${held.expire_at}`);
809
- const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [] };
861
+ const channel = { label: "board", read_key: thread.id, w: thread.w, expire_at: thread.expireAt, allow: ["*"], after: 0, created_at: null, instance: null, gone: false, muted: false, seen: [], seen_from: [] };
810
862
  this.state.channels.push(channel);
811
863
  try {
812
864
  await this.saveState();
@@ -967,10 +1019,12 @@ export class NodeRuntime {
967
1019
  const [body, meta] = this.openMessage({ ...message, verified: checked.verified, from: sender });
968
1020
  const known = this.nameForKey(sender);
969
1021
  const digest = checked.sha256 ?? null;
1022
+ const replay = replayState(channel, digest, sender);
970
1023
  const entry = {
971
1024
  channel: channel.label, w: channel.w, seq, at, verified: checked.verified, from_key: sender, known_contact: known !== null,
972
- sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: digest !== null && channel.seen.includes(digest), body, ...meta,
1025
+ sender: known ?? (sender ? "unknown key" : "unsigned"), sha256: digest, replay: replay === true, body, ...meta,
973
1026
  };
1027
+ if (replay === null) entry.replay_unknown = true;
974
1028
  if (checked.whyNot) entry.unverified_because = checked.whyNot;
975
1029
  return entry;
976
1030
  } catch (error) {
@@ -1033,7 +1087,7 @@ export class NodeRuntime {
1033
1087
  */
1034
1088
  async poll(channel, wait = 0, limit = null, maxBytes = null) {
1035
1089
  if (channel.muted) return [];
1036
- const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], peers: { ...this.state.peers } };
1090
+ const before = { after: channel.after, created_at: channel.created_at, instance: channel.instance ?? null, expire_at: channel.expire_at, gone: channel.gone, seen: [...channel.seen], seen_from: [...(channel.seen_from ?? [])], peers: { ...this.state.peers } };
1037
1091
  try {
1038
1092
  return await this.pollOnce(channel, wait, limit, true, maxBytes);
1039
1093
  } catch (error) {
@@ -1041,7 +1095,7 @@ export class NodeRuntime {
1041
1095
  // What this read changed is undone in memory too: the cursor, the hashes
1042
1096
  // and the bindings stay as the file has them, and the messages wait at
1043
1097
  // the service for the next read, which hands them over.
1044
- Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen });
1098
+ Object.assign(channel, { after: before.after, created_at: before.created_at, instance: before.instance, expire_at: before.expire_at, gone: before.gone, seen: before.seen, seen_from: before.seen_from });
1045
1099
  this.state.peers = before.peers;
1046
1100
  this.note(channel.label, "not_saved", `what this read found here could not be written down (${error.cause?.code || error.cause?.message}), so nothing from this channel was handed over. It is still there for the next read, and nothing was skipped.`, channel.w);
1047
1101
  return [];
@@ -1144,7 +1198,7 @@ export class NodeRuntime {
1144
1198
  // is passed over by its hash, which is the hash of the text alone: another
1145
1199
  // sender's message with the same text has it too (the same review).
1146
1200
  if (!another && !data.reset && channel.after > 0 && typeof channel.instance !== "string" && instance !== null) {
1147
- this.note(channel.label, "reread", `this channel's cursor, ${channel.after}, was kept by a client that did not keep the thread's instance, so it cannot be told whether the thread here is the one that cursor was kept on. It is read again from the start this once: what this channel handed over before comes again, marked replay, and nothing is passed over.`, channel.w);
1201
+ this.note(channel.label, "reread", `this channel's cursor, ${channel.after}, was kept by a client that did not keep the thread's instance, so it cannot be told whether the thread here is the one that cursor was kept on. It is read again from the start this once: what this channel handed over before comes again, marked replay, or replay_unknown where an earlier version kept it without who sent it, and nothing is passed over.`, channel.w);
1148
1202
  channel.after = 0;
1149
1203
  return this.pollOnce(channel, 0, limit, false, maxBytes);
1150
1204
  }
@@ -1182,6 +1236,11 @@ export class NodeRuntime {
1182
1236
  let used = 0;
1183
1237
  let stoppedAtBudget = false;
1184
1238
  let oversized = null;
1239
+ // What this read says of the messages it takes is said once the read is
1240
+ // written down. One that cannot be saved hands nothing over, and it said
1241
+ // replay_unknown for rows it never handed over: count 0 and no messages,
1242
+ // with a note on two of them (a review of 11 November 2026).
1243
+ const said = [];
1185
1244
  for (const raw of data.messages) {
1186
1245
  if (limit !== null && entries.length >= limit) break;
1187
1246
  const entry = this.readEntry(channel, raw);
@@ -1221,39 +1280,46 @@ export class NodeRuntime {
1221
1280
  keptOut.push(entry.seq);
1222
1281
  continue;
1223
1282
  }
1224
- if (entry.sha256 !== null && !channel.seen.includes(entry.sha256)) channel.seen.push(entry.sha256);
1283
+ if (entry.sha256 !== null) remember(channel, entry.sha256, entry.from_key);
1225
1284
  for (const planned of bindings) {
1226
1285
  if (planned.conflict === null) {
1227
1286
  this.state.peers[planned.address] = entry.from_key;
1228
1287
  } else {
1229
- this.note(channel.label, "binding_conflict", `message ${entry.seq} names ${planned.address} as ${planned.field}, signed by ${this.nameForKey(entry.from_key) ?? entry.from_key}, and that address is already bound to ${this.nameForKey(planned.conflict.bound_to) ?? planned.conflict.bound_to}. The binding is kept.`, channel.w);
1288
+ said.push(["binding_conflict", `message ${entry.seq} names ${planned.address} as ${planned.field}, signed by ${this.nameForKey(entry.from_key) ?? entry.from_key}, and that address is already bound to ${this.nameForKey(planned.conflict.bound_to) ?? planned.conflict.bound_to}. The binding is kept.`]);
1230
1289
  }
1231
1290
  }
1232
1291
  entries.push(entry);
1233
1292
  }
1293
+ // Messages whose bytes this channel handed over before, which an earlier
1294
+ // version recorded without who sent them: whether each is a copy cannot be
1295
+ // told, and it is said rather than guessed.
1296
+ const unknown = entries.filter((entry) => entry.replay_unknown).map((entry) => entry.seq);
1297
+ if (unknown.length) {
1298
+ said.push(["replay_unknown", `message(s) ${unknown.join(", ")} carry the bytes of a message this channel handed over before, which an earlier version recorded without who sent it, so whether each is a copy cannot be told: each comes with replay false and replay_unknown true.`]);
1299
+ }
1234
1300
  if (keptOut.length) {
1235
1301
  const openedFor = (channel.allow || []).includes("*") ? "any key, signed only" : `${channel.allow.length} named key(s)`;
1236
- this.note(channel.label, "kept_out", `${keptOut.length} message(s) (seq ${keptOut.join(", ")}): this channel was opened for ${openedFor}, and these did not satisfy that list as checked here. They are not handed over.`, channel.w);
1302
+ said.push(["kept_out", `${keptOut.length} message(s) (seq ${keptOut.join(", ")}): this channel was opened for ${openedFor}, and these did not satisfy that list as checked here. They are not handed over.`]);
1237
1303
  }
1238
1304
  if (oversized) {
1239
- this.note(channel.label, "over_budget", `message ${oversized.seq} on this channel is ${oversized.weight} bytes as handed over here, larger than the whole byte budget of ${maxBytes} this read asked for. It had already come, so it is handed over on its own rather than kept back for ever, and this read is over its budget on purpose.`, channel.w);
1305
+ said.push(["over_budget", `message ${oversized.seq} on this channel is ${oversized.weight} bytes as handed over here, larger than the whole byte budget of ${maxBytes} this read asked for. It had already come, so it is handed over on its own rather than kept back for ever, and this read is over its budget on purpose.`]);
1240
1306
  }
1241
1307
  // Rows the service sent that this read left because its own limit was
1242
1308
  // reached: a service that does not take X-Limit sends them all, and the read
1243
1309
  // that stopped on its own said nothing (a review of 10 November 2026).
1244
1310
  const leftHere = !stoppedAtBudget && limit !== null && entries.length >= limit && data.messages.length > entries.length + keptOut.length;
1245
1311
  if (stoppedAtBudget) {
1246
- this.note(channel.label, "more", `this read stopped at the byte budget it asked for, ${maxBytes} bytes of messages as they are handed over here, and left the rest at the service. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.`, channel.w);
1312
+ said.push(["more", `this read stopped at the byte budget it asked for, ${maxBytes} bytes of messages as they are handed over here, and left the rest at the service. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.`]);
1247
1313
  } else if (leftHere || (data.more === true && !isObject(data.too_large))) {
1248
1314
  // The service says more both when the count this read asked for was
1249
1315
  // reached and when the byte budget was. A note that blamed a budget
1250
1316
  // nobody set would send the reader looking for the wrong cause.
1251
1317
  const atLimit = leftHere || (limit !== null && data.messages.length >= limit);
1252
- this.note(channel.label, "more", atLimit
1318
+ said.push(["more", atLimit
1253
1319
  ? "this read handed over as many messages as its limit allows, and the service has more waiting on this channel. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."
1254
1320
  : maxBytes !== null
1255
1321
  ? "the service had more waiting on this channel than the byte budget this read asked for, so it sent what fits and kept the rest. Read again for it: the cursor stands at the last message handed over."
1256
- : "the service says it has more waiting on this channel than it sent. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest.", channel.w);
1322
+ : "the service says it has more waiting on this channel than it sent. Nothing was passed over: the cursor stands at the last message handed over, so read again for the rest."]);
1257
1323
  }
1258
1324
  // A read that stopped early moves the cursor to the last row it took and
1259
1325
  // no further: next would skip the rest of the page.
@@ -1263,6 +1329,7 @@ export class NodeRuntime {
1263
1329
  } else if (Number.isInteger(data.next)) channel.after = data.next;
1264
1330
  else if (lastSeq !== null) channel.after = Math.max(channel.after, lastSeq);
1265
1331
  await this.saveRead();
1332
+ for (const [state, what] of said) this.note(channel.label, state, what, channel.w);
1266
1333
  return entries;
1267
1334
  }
1268
1335