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 +8 -2
- package/package.json +1 -1
- package/src/node-runtime.js +80 -13
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
|
|
62
|
-
back marked `replay`, also after a restart
|
|
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.
|
|
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",
|
package/src/node-runtime.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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."
|
|
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
|
|