@adapt-toolkit/a2adapt 0.4.1 → 0.5.0

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
@@ -6,14 +6,15 @@ agent-to-agent messaging tools over stdio. Distributed as part of the
6
6
 
7
7
  The server **is** the node: on startup it boots a single ADAPT packet (a MUFL
8
8
  messenger), restores prior state from the state dir, connects to the broker, and
9
- exposes six tools — each a thin wrapper over one MUFL user transaction:
9
+ exposes the messaging tools — each a thin wrapper over one MUFL user transaction:
10
10
 
11
11
  - `generate_invite` — named invite to share out-of-band
12
12
  - `add_contact` — add a contact from an invite blob (TOFU)
13
13
  - `list_contacts`
14
14
  - `send_message` — end-to-end encrypted
15
- - `process_incoming_message` — drain + decrypt inbound from the broker
16
- - `list_incoming_messages`
15
+ - `get_messages` — return unread messages (bodies) + mark read; delivered exactly once
16
+ - `mark_processed` / `defer_messages` — remove handled messages, or re-queue read ones for another session
17
+ - `list_incoming_messages` — full inbox with ids + status (read-only)
17
18
 
18
19
  ## Configuration
19
20
 
package/dist/cli.js CHANGED
@@ -161,7 +161,7 @@ function cmdWatch(which) {
161
161
  }
162
162
  for (const name of names) {
163
163
  if (which && name !== which) continue;
164
- const logPath = join(STATE_DIR, name, "inbox.log");
164
+ const logPath = join(STATE_DIR, name, "notifications.log");
165
165
  let size;
166
166
  try {
167
167
  size = fs.statSync(logPath).size;
@@ -198,7 +198,7 @@ function cmdWatch(which) {
198
198
  continue;
199
199
  }
200
200
  out(
201
- `[${name}] new message from ${msg.sender ?? "?"}: ${msg.text ?? ""}` + (msg.date ? ` (${msg.date})` : "")
201
+ `[${name}] new message from ${msg.from ?? "?"}` + (msg.msg_id !== void 0 ? ` (#${msg.msg_id})` : "") + (msg.date ? ` (${msg.date})` : "")
202
202
  );
203
203
  }
204
204
  }
@@ -21,34 +21,26 @@ function emit(payload) {
21
21
  function noop() {
22
22
  emit({ continue: true });
23
23
  }
24
- function readCursor(dir) {
24
+ function readUnreadSnapshot(dir) {
25
+ let raw;
25
26
  try {
26
- return parseInt(fs.readFileSync(join(dir, "inbox_cursor"), "utf8").trim(), 10) || 0;
27
+ raw = fs.readFileSync(join(dir, "unread.json"), "utf8");
27
28
  } catch {
28
- return 0;
29
+ return null;
29
30
  }
30
- }
31
- function readInboxLog(dir) {
32
- let raw;
33
31
  try {
34
- raw = fs.readFileSync(join(dir, "inbox.log"), "utf8");
32
+ const snap = JSON.parse(raw);
33
+ const count = Number(snap.count ?? 0);
34
+ if (!count) return null;
35
+ const recent = Array.isArray(snap.recent) ? snap.recent.map((m) => ({
36
+ from: String(m.from ?? "?"),
37
+ msg_id: m.msg_id ?? "?",
38
+ date: String(m.date ?? "")
39
+ })) : [];
40
+ return { name: "", count, recent };
35
41
  } catch {
36
- return [];
37
- }
38
- const msgs = [];
39
- for (const line of raw.split("\n")) {
40
- if (!line.trim()) continue;
41
- try {
42
- const m = JSON.parse(line);
43
- msgs.push({
44
- sender: String(m.sender ?? "?"),
45
- text: String(m.text ?? ""),
46
- date: String(m.date ?? "")
47
- });
48
- } catch {
49
- }
42
+ return null;
50
43
  }
51
- return msgs;
52
44
  }
53
45
  function collectUnread() {
54
46
  let names;
@@ -59,12 +51,9 @@ function collectUnread() {
59
51
  }
60
52
  const out = [];
61
53
  for (const name of names) {
62
- const dir = join(STATE_DIR, name);
63
- const all = readInboxLog(dir);
64
- if (all.length === 0) continue;
65
- const unread = all.slice(readCursor(dir));
66
- if (unread.length === 0) continue;
67
- out.push({ name, count: unread.length, messages: unread });
54
+ const snap = readUnreadSnapshot(join(STATE_DIR, name));
55
+ if (!snap) continue;
56
+ out.push({ ...snap, name });
68
57
  }
69
58
  return out;
70
59
  }
@@ -73,15 +62,15 @@ function renderContext(unread) {
73
62
  const lines = [];
74
63
  for (const u of unread) {
75
64
  lines.push(`\u2022 ${u.name} \u2014 ${u.count} unread:`);
76
- for (const m of u.messages.slice(-5)) {
77
- lines.push(` [${m.sender}] ${m.text}${m.date ? ` (${m.date})` : ""}`);
65
+ for (const m of u.recent.slice(-5)) {
66
+ lines.push(` from ${m.from} (#${m.msg_id})${m.date ? ` (${m.date})` : ""}`);
78
67
  }
79
- if (u.count > 5) lines.push(` \u2026and ${u.count - 5} earlier`);
68
+ if (u.count > u.recent.length) lines.push(` \u2026and ${u.count - u.recent.length} earlier`);
80
69
  }
81
- return `a2adapt \u2014 ${total} unread message(s) across ${unread.length} identit${unread.length === 1 ? "y" : "ies"} (arrived while you were away):
70
+ return `a2adapt \u2014 ${total} unread message(s) across ${unread.length} identit${unread.length === 1 ? "y" : "ies"} (arrived while you were away; senders shown, bodies stay in the packet):
82
71
  ${lines.join("\n")}
83
72
 
84
- To read & clear: choose_identity({ name }) then process_incoming_message(). To wait for live replies, arm a Monitor on the wake source \`a2adapt-mcp watch\` (each new-mail line wakes you).`;
73
+ To read: choose_identity({ name }) then get_messages() (returns the bodies and marks them read). To wait for live replies, arm a Monitor on the per-identity wake source \`a2adapt-mcp watch <name>\` (each new-mail line wakes you).`;
85
74
  }
86
75
  function sessionStart() {
87
76
  const raw = readStdin();
package/dist/index.js CHANGED
@@ -22433,10 +22433,11 @@ import { fileURLToPath } from "node:url";
22433
22433
  import { randomBytes, randomUUID } from "node:crypto";
22434
22434
  import { createServer as createHttpServer } from "node:http";
22435
22435
  import * as fs from "node:fs";
22436
+ import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from "node:zlib";
22436
22437
  import { adapt_wrapper } from "@adapt-toolkit/sdk/executables";
22437
22438
  import { PacketWrapperConfigurator } from "@adapt-toolkit/sdk/wrappers";
22438
22439
  import { object_to_adapt_value } from "@adapt-toolkit/sdk/wrapper";
22439
- var VERSION = true ? "0.4.1" : "0.0.0-dev";
22440
+ var VERSION = true ? "0.5.0" : "0.0.0-dev";
22440
22441
  var STATE_DIR = resolve(
22441
22442
  process.env.A2ADAPT_STATE_DIR ?? resolve(homedir(), ".a2adapt")
22442
22443
  );
@@ -22481,8 +22482,8 @@ var evictedSessions = /* @__PURE__ */ new Set();
22481
22482
  var identityDir = (name) => join(STATE_DIR, name);
22482
22483
  var seedPath = (dir) => join(dir, "identity.seed");
22483
22484
  var dataPath = (dir) => join(dir, "state_data.bin");
22484
- var cursorPath = (dir) => join(dir, "inbox_cursor");
22485
- var inboxLogPath = (dir) => join(dir, "inbox.log");
22485
+ var notifyLogPath = (dir) => join(dir, "notifications.log");
22486
+ var unreadPath = (dir) => join(dir, "unread.json");
22486
22487
  function listPersistedNames() {
22487
22488
  if (!fs.existsSync(STATE_DIR)) return [];
22488
22489
  return fs.readdirSync(STATE_DIR, { withFileTypes: true }).filter((d) => d.isDirectory() && fs.existsSync(seedPath(join(STATE_DIR, d.name)))).map((d) => d.name);
@@ -22508,43 +22509,47 @@ function saveState(id) {
22508
22509
  log(`[${id.name}] failed to save state:`, String(err));
22509
22510
  }
22510
22511
  }
22511
- function readCursor(dir) {
22512
+ function appendNotifyLog(id, from, msgId, date3) {
22512
22513
  try {
22513
- return parseInt(fs.readFileSync(cursorPath(dir), "utf8").trim(), 10) || 0;
22514
- } catch {
22515
- return 0;
22516
- }
22517
- }
22518
- function writeCursor(dir, n) {
22519
- try {
22520
- fs.mkdirSync(dir, { recursive: true });
22521
- fs.writeFileSync(cursorPath(dir), String(n));
22522
- } catch {
22514
+ fs.mkdirSync(id.dir, { recursive: true });
22515
+ const line = JSON.stringify({ event: "message_received", from, msg_id: msgId, date: date3 }) + "\n";
22516
+ fs.appendFileSync(notifyLogPath(id.dir), line);
22517
+ } catch (err) {
22518
+ log(`[${id.name}] failed to append notifications.log:`, String(err));
22523
22519
  }
22524
22520
  }
22525
- function appendInboxLog(id, sender, text, date3) {
22521
+ function refreshUnread(id) {
22526
22522
  try {
22523
+ const inbox = renderInbox(readonlyTx(id, "::actor::list_incoming_messages"));
22524
+ const unread = inbox.filter((m) => m.status === "unread");
22525
+ const snapshot = {
22526
+ count: unread.length,
22527
+ recent: unread.slice(-10).map((m) => ({ from: m.sender_name, msg_id: m.msg_id, date: m.date }))
22528
+ };
22527
22529
  fs.mkdirSync(id.dir, { recursive: true });
22528
- const line = JSON.stringify({ sender, text, date: date3 }) + "\n";
22529
- fs.appendFileSync(inboxLogPath(id.dir), line);
22530
+ const tmp = `${unreadPath(id.dir)}.tmp`;
22531
+ fs.writeFileSync(tmp, JSON.stringify(snapshot));
22532
+ fs.renameSync(tmp, unreadPath(id.dir));
22530
22533
  } catch (err) {
22531
- log(`[${id.name}] failed to append inbox.log:`, String(err));
22534
+ log(`[${id.name}] failed to refresh unread snapshot:`, String(err));
22532
22535
  }
22533
22536
  }
22534
- var activeMcpServers = /* @__PURE__ */ new Set();
22537
+ var serversBySession = /* @__PURE__ */ new Map();
22535
22538
  var inboxResourceUri = (name) => `a2adapt://inbox/${encodeURIComponent(name)}`;
22536
22539
  function pushNotification(identityName, summary) {
22537
22540
  log(`[${identityName}] notify:`, summary);
22538
- for (const server of activeMcpServers) {
22539
- try {
22540
- server.sendLoggingMessage({ level: "info", logger: "a2adapt", data: summary });
22541
- } catch (e) {
22542
- log("sendLoggingMessage failed:", String(e));
22543
- }
22544
- try {
22545
- server.server.sendResourceUpdated({ uri: inboxResourceUri(identityName) });
22546
- } catch {
22547
- }
22541
+ const sid = bindingOwner.get(identityName);
22542
+ if (!sid) return;
22543
+ const server = serversBySession.get(sid);
22544
+ if (!server) return;
22545
+ try {
22546
+ server.sendLoggingMessage({ level: "info", logger: "a2adapt", data: summary });
22547
+ } catch (e) {
22548
+ log("sendLoggingMessage failed:", String(e));
22549
+ }
22550
+ try {
22551
+ server.server.sendResourceUpdated({ uri: inboxResourceUri(identityName) });
22552
+ } catch {
22548
22553
  }
22549
22554
  }
22550
22555
  function readonlyTx(id, name) {
@@ -22590,11 +22595,12 @@ function wireHandlers(id) {
22590
22595
  const event = payload.Reduce("event").Visualize();
22591
22596
  if (event === "message_received") {
22592
22597
  const sender = payload.Reduce("sender_name").Visualize();
22593
- const text = payload.Reduce("text").Visualize();
22598
+ const msgId = payload.Reduce("msg_id").Visualize();
22594
22599
  const date3 = payload.Reduce("date").Visualize();
22595
- appendInboxLog(id, sender, text, date3);
22600
+ appendNotifyLog(id, sender, msgId, date3);
22601
+ refreshUnread(id);
22596
22602
  process.nextTick(
22597
- () => pushNotification(id.name, `[${id.name}] new message from ${sender}: ${text} (${date3})`)
22603
+ () => pushNotification(id.name, `[${id.name}] new message from ${sender} (#${msgId})`)
22598
22604
  );
22599
22605
  } else if (event === "contact_accepted") {
22600
22606
  const name = payload.Reduce("name").Visualize();
@@ -22680,6 +22686,7 @@ async function restoreIdentity(name) {
22680
22686
  log(`[${name}] failed to import saved state (continuing fresh):`, String(err));
22681
22687
  }
22682
22688
  }
22689
+ refreshUnread(id);
22683
22690
  return id;
22684
22691
  }
22685
22692
  async function bootWrapper() {
@@ -22758,10 +22765,12 @@ function renderInbox(v) {
22758
22765
  const m = v.Reduce(i);
22759
22766
  if (m.IsNil()) break;
22760
22767
  out.push({
22768
+ msg_id: parseInt(m.Reduce("msg_id").Visualize(), 10),
22761
22769
  sender_id: m.Reduce("sender_id").Visualize(),
22762
22770
  sender_name: m.Reduce("sender_name").Visualize(),
22763
22771
  text: m.Reduce("text").Visualize(),
22764
- date: m.Reduce("date").Visualize()
22772
+ date: m.Reduce("date").Visualize(),
22773
+ status: m.Reduce("status").Visualize()
22765
22774
  });
22766
22775
  }
22767
22776
  return out;
@@ -22769,12 +22778,32 @@ function renderInbox(v) {
22769
22778
  function textResult(text, isError = false) {
22770
22779
  return { content: [{ type: "text", text }], isError };
22771
22780
  }
22781
+ var INVITE_BROTLI_V1 = 1;
22782
+ function packInvite(raw) {
22783
+ const compressed = brotliCompressSync(raw, {
22784
+ params: {
22785
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 11,
22786
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: raw.length
22787
+ }
22788
+ });
22789
+ return Buffer.concat([Buffer.from([INVITE_BROTLI_V1]), compressed]).toString("base64");
22790
+ }
22791
+ function unpackInvite(b64) {
22792
+ const outer = Buffer.from(b64.trim(), "base64");
22793
+ if (outer.length < 2) throw new Error("the invite blob is too short or not valid base64");
22794
+ const version2 = outer[0];
22795
+ if (version2 === INVITE_BROTLI_V1) return Buffer.from(brotliDecompressSync(outer.subarray(1)));
22796
+ throw new Error(`unsupported invite format (version ${version2}) \u2014 the sender may be on a different a2adapt version`);
22797
+ }
22798
+ function fmtMsg(m, withStatus = true) {
22799
+ const status = withStatus && m.status && m.status !== "unread" ? ` [${m.status}]` : "";
22800
+ return `#${m.msg_id} [${m.sender_name}]${status} ${m.text} (${m.date})`;
22801
+ }
22772
22802
  function createMcpServer(getSessionId) {
22773
22803
  const server = new McpServer(
22774
22804
  { name: "a2adapt", version: VERSION },
22775
22805
  { capabilities: { logging: {}, resources: {} } }
22776
22806
  );
22777
- activeMcpServers.add(server);
22778
22807
  const boundOr = () => {
22779
22808
  const b = resolveBound(getSessionId());
22780
22809
  return "error" in b ? { err: textResult(b.error, true) } : { id: b.id };
@@ -22885,7 +22914,7 @@ ${lines.join("\n")}`);
22885
22914
  if (err || !id) return { contents: [{ uri, mimeType: "text/plain", text: "No identity bound to this session." }] };
22886
22915
  const inbox = renderInbox(readonlyTx(id, "::actor::list_incoming_messages"));
22887
22916
  const text = inbox.length === 0 ? "Inbox is empty." : `Inbox (${inbox.length}):
22888
- ${inbox.map((m, i) => `${i + 1}. [${m.sender_name}] ${m.text} (${m.date})`).join("\n")}`;
22917
+ ${inbox.map((m) => fmtMsg(m)).join("\n")}`;
22889
22918
  return { contents: [{ uri, mimeType: "text/plain", text }] };
22890
22919
  }
22891
22920
  );
@@ -22898,7 +22927,7 @@ ${inbox.map((m, i) => `${i + 1}. [${m.sender_name}] ${m.text} (${m.date})`).joi
22898
22927
  if (err) return err;
22899
22928
  try {
22900
22929
  const data = await mutatingTx(id, "::actor::generate_invite", { name });
22901
- const blob = Buffer.from(data.Reduce("invite").GetBinary()).toString("base64");
22930
+ const blob = packInvite(Buffer.from(data.Reduce("invite").GetBinary()));
22902
22931
  return textResult(
22903
22932
  `Invite for "${name}" created. Share this blob out-of-band (they paste it into add_contact):
22904
22933
 
@@ -22921,10 +22950,10 @@ ${blob}`
22921
22950
  if (err) return err;
22922
22951
  let buf;
22923
22952
  try {
22924
- buf = Buffer.from(invite.trim(), "base64");
22925
- if (buf.length === 0) throw new Error("empty");
22926
- } catch {
22927
- return textResult("add_contact failed: the invite blob is not valid base64.", true);
22953
+ buf = unpackInvite(invite);
22954
+ if (buf.length === 0) throw new Error("the invite blob is empty");
22955
+ } catch (e) {
22956
+ return textResult(`add_contact failed: ${e instanceof Error ? e.message : "the invite blob is not valid."}`, true);
22928
22957
  }
22929
22958
  try {
22930
22959
  const blobValue = id.pw.packet.NewBinaryFromBuffer(buf);
@@ -22976,7 +23005,7 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
22976
23005
  );
22977
23006
  server.tool(
22978
23007
  "list_incoming_messages",
22979
- "List all received messages in the bound identity's inbox (decrypted, with sender).",
23008
+ "List ALL messages in the bound identity's inbox (decrypted), each with its id and status (unread/read). A read-only history view \u2014 it does not change any status. To consume new mail use get_messages instead.",
22980
23009
  {},
22981
23010
  async () => {
22982
23011
  const { id, err } = boundOr();
@@ -22984,30 +23013,70 @@ ${contacts.map((c) => `\u2022 ${c.name} \u2014 ${c.container_id}`).join("\n")}`)
22984
23013
  try {
22985
23014
  const inbox = renderInbox(readonlyTx(id, "::actor::list_incoming_messages"));
22986
23015
  if (inbox.length === 0) return textResult("Inbox is empty.");
22987
- return textResult(`Inbox (${inbox.length}):
22988
- ${inbox.map((m, i) => `${i + 1}. [${m.sender_name}] ${m.text} (${m.date})`).join("\n")}`);
23016
+ const unread = inbox.filter((m) => m.status === "unread").length;
23017
+ return textResult(
23018
+ `Inbox (${inbox.length}, ${unread} unread):
23019
+ ${inbox.map((m) => fmtMsg(m)).join("\n")}`
23020
+ );
22989
23021
  } catch (e) {
22990
23022
  return textResult(`list_incoming_messages failed: ${String(e)}`, true);
22991
23023
  }
22992
23024
  }
22993
23025
  );
22994
23026
  server.tool(
22995
- "process_incoming_message",
22996
- "Surface messages that have arrived for the bound identity since the last check, and mark them processed (non-blocking \u2014 use to poll for new mail).",
23027
+ "get_messages",
23028
+ 'Fetch the messages the bound identity has not seen yet (status "unread") and mark them "read". This is the ONLY call that returns message bodies. A message is delivered here exactly once, so reading and acting on it immediately will not double-process. Note each message id: pass them to mark_processed when done, or to defer_messages to leave a message for another session.',
22997
23029
  {},
22998
23030
  async () => {
22999
23031
  const { id, err } = boundOr();
23000
23032
  if (err) return err;
23001
23033
  try {
23002
- const inbox = renderInbox(readonlyTx(id, "::actor::list_incoming_messages"));
23003
- const cursor = readCursor(id.dir);
23004
- const fresh = inbox.slice(cursor);
23005
- writeCursor(id.dir, inbox.length);
23034
+ const data = await mutatingTx(id, "::actor::get_messages", {});
23035
+ const fresh = renderInbox(data.Reduce("messages"));
23036
+ refreshUnread(id);
23006
23037
  if (fresh.length === 0) return textResult("No new messages.");
23007
- return textResult(`${fresh.length} new message(s):
23008
- ${fresh.map((m) => `\u2022 [${m.sender_name}] ${m.text} (${m.date})`).join("\n")}`);
23038
+ return textResult(
23039
+ `${fresh.length} new message(s):
23040
+ ${fresh.map((m) => fmtMsg(m, false)).join("\n")}
23041
+
23042
+ When handled: mark_processed({ msg_ids: [${fresh.map((m) => m.msg_id).join(", ")}] }). To leave any for another session: defer_messages({ msg_ids: [...] }).`
23043
+ );
23044
+ } catch (e) {
23045
+ return textResult(`get_messages failed: ${String(e)}`, true);
23046
+ }
23047
+ }
23048
+ );
23049
+ server.tool(
23050
+ "mark_processed",
23051
+ "Mark the given messages handled \u2014 they are removed from the inbox permanently. Idempotent: ids already gone are ignored. Optional for correctness (get_messages already prevents re-delivery); use it to keep the inbox tidy.",
23052
+ { msg_ids: external_exports.array(external_exports.number().int()).min(1).describe("Message ids (from get_messages) to mark processed.") },
23053
+ async ({ msg_ids }) => {
23054
+ const { id, err } = boundOr();
23055
+ if (err) return err;
23056
+ try {
23057
+ const data = await mutatingTx(id, "::actor::mark_processed", { msg_ids });
23058
+ const n = data.Reduce("processed").Visualize();
23059
+ refreshUnread(id);
23060
+ return textResult(`Marked ${n} message(s) processed.`);
23061
+ } catch (e) {
23062
+ return textResult(`mark_processed failed: ${String(e)}`, true);
23063
+ }
23064
+ }
23065
+ );
23066
+ server.tool(
23067
+ "defer_messages",
23068
+ `Put already-read messages back into the queue (status "unread") so another session's get_messages will pick them up. Use when you read a message but want to leave it for someone else to process.`,
23069
+ { msg_ids: external_exports.array(external_exports.number().int()).min(1).describe("Message ids (from get_messages) to defer back to unread.") },
23070
+ async ({ msg_ids }) => {
23071
+ const { id, err } = boundOr();
23072
+ if (err) return err;
23073
+ try {
23074
+ const data = await mutatingTx(id, "::actor::defer_messages", { msg_ids });
23075
+ const n = data.Reduce("deferred").Visualize();
23076
+ refreshUnread(id);
23077
+ return textResult(`Deferred ${n} message(s) back to unread.`);
23009
23078
  } catch (e) {
23010
- return textResult(`process_incoming_message failed: ${String(e)}`, true);
23079
+ return textResult(`defer_messages failed: ${String(e)}`, true);
23011
23080
  }
23012
23081
  }
23013
23082
  );
@@ -23030,6 +23099,7 @@ function readBody(req) {
23030
23099
  async function main() {
23031
23100
  if (TRANSPORT === "stdio") {
23032
23101
  const server = createMcpServer(() => "stdio");
23102
+ serversBySession.set("stdio", server);
23033
23103
  const transport = new StdioServerTransport();
23034
23104
  await server.connect(transport);
23035
23105
  log("MCP stdio transport connected, booting wrapper\u2026");
@@ -23065,6 +23135,7 @@ async function main() {
23065
23135
  sessionIdGenerator: () => randomUUID(),
23066
23136
  onsessioninitialized: (sid) => {
23067
23137
  transports[sid] = transport;
23138
+ serversBySession.set(sid, server);
23068
23139
  log(`session ${sid.slice(0, 8)}\u2026 initialized`);
23069
23140
  }
23070
23141
  });
@@ -23073,13 +23144,13 @@ async function main() {
23073
23144
  const sid = transport.sessionId;
23074
23145
  if (sid) {
23075
23146
  delete transports[sid];
23147
+ serversBySession.delete(sid);
23076
23148
  const name = sessionBinding.get(sid);
23077
23149
  if (name && bindingOwner.get(name) === sid) bindingOwner.delete(name);
23078
23150
  sessionBinding.delete(sid);
23079
23151
  evictedSessions.delete(sid);
23080
23152
  log(`session ${sid.slice(0, 8)}\u2026 closed`);
23081
23153
  }
23082
- activeMcpServers.delete(server);
23083
23154
  };
23084
23155
  await server.connect(transport);
23085
23156
  await transport.handleRequest(req, res, body);
@@ -7,11 +7,14 @@
7
7
  //
8
8
  // User transactions (each backs one MCP tool):
9
9
  // set_my_name — set the display name peers see for me
10
- // generate_invite — make a personal invite blob for a named peer
10
+ // generate_invite — make a slim personal invite blob for a named peer
11
11
  // add_contact — join via an invite blob, reply to the inviter
12
12
  // send_message — send an e2e-encrypted message to a contact
13
13
  // list_contacts — (readonly) my contacts
14
- // list_incoming_messages — (readonly) my inbox
14
+ // list_incoming_messages — (readonly) my inbox, with per-message id + status
15
+ // get_messages — return unread messages + mark them read (sole body egress)
16
+ // mark_processed — delete handled messages from the inbox
17
+ // defer_messages — flip read messages back to unread for another session
15
18
  //
16
19
  // External transactions (inbound, not exposed as tools):
17
20
  // accept_contact — inviter learns the joiner's identity + name
@@ -39,8 +42,27 @@ application actor loads libraries
39
42
  hidden
40
43
  {
41
44
  metadef contact_t: ($name -> str, $container_id -> global_id).
42
- metadef invite_t: ($invite_id -> global_id, $inviter_id -> global_id, $inviter_name -> str, $inviter_ad -> address_document_types::t_address_document).
43
- metadef message_t: ($sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time).
45
+ // Slim invite. Short keys (no field-name bloat), no outer wrapper, and
46
+ // only the cryptographically load-bearing parts travel: the inviter's
47
+ // signed identity (public keys) + its self-signatures. inviter_id is NOT
48
+ // carried separately — it is the identity's own container_id. version is
49
+ // a constant reconstructed on the receiver. (See generate_invite /
50
+ // add_contact: the embedded identity is kept byte-for-byte so its
51
+ // _value_id — what the signatures are over — stays valid.)
52
+ // $d invite_id $n inviter_name $c container_id
53
+ // $k public keys $a authorizations
54
+ // default_keys is NOT carried — the receiver rebuilds it from the keys
55
+ // (each key knows its own function + id), so the reconstructed identity is
56
+ // byte-identical to the signed one and the self-signatures still verify.
57
+ metadef invite_t: ($d -> global_id, $n -> str, $c -> global_id, $k -> key_utils::t_publickey(,), $a -> crypto_signature(,)).
58
+ // A received message carries a stable per-packet id and a lifecycle
59
+ // status: "unread" (just arrived) -> "read" (handed to the agent via
60
+ // get_messages). mark_processed deletes it; defer_messages flips it back
61
+ // to "unread" so another session can pick it up.
62
+ metadef message_t: ($msg_id -> int, $sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time, $status -> str).
63
+ // The pre-lifecycle inbox shape (no per-message id/status). import_state
64
+ // migrates blobs in this shape forward — see below.
65
+ metadef legacy_message_t: ($sender_id -> global_id, $sender_name -> str, $text -> str, $date -> time).
44
66
 
45
67
  // Wire the deserialization primitive into the libraries that need it.
46
68
  _read_or_abort = grab( _read_or_abort ).
@@ -54,8 +76,14 @@ application actor loads libraries
54
76
  contacts is (global_id ->> contact_t) = (,).
55
77
  // Invites I generated, keyed by invite id -> the name I assigned the peer.
56
78
  pending_invites is (global_id ->> str) = (,).
57
- // Received messages, append-only (the host tracks how many it surfaced).
79
+ // Received messages. Each carries its own lifecycle status (see
80
+ // message_t / get_messages / mark_processed / defer_messages), so the
81
+ // packet is the single authority on what has been read or processed —
82
+ // no host-side cursor, safe across concurrent sessions.
58
83
  inbox is message_t[] = [].
84
+ // Monotonic source of per-message ids (the stable handle the agent uses
85
+ // to mark a message processed or defer it).
86
+ next_msg_seq is int = 1.
59
87
  // Peer address documents, captured when a contact is established. These are
60
88
  // self-signed, code-independent, and seed-stable, so on a code upgrade the
61
89
  // host re-creates this packet from the same seed and import_state replays
@@ -105,13 +133,20 @@ application actor loads libraries
105
133
  // Remember the name I'm assigning to whoever redeems this invite.
106
134
  pending_invites invite_id -> name.
107
135
 
136
+ // Carry only my signed identity (public keys) + its self-signatures. The
137
+ // joiner rebuilds my address document from these (version is constant, my
138
+ // container_id is the identity's own field) and stores it so it can
139
+ // re-register me after a code upgrade (see peer_ads / import_state). The
140
+ // identity sub-record is passed through untouched so its _value_id — what
141
+ // the authorizations sign over — survives the round trip unchanged.
142
+ my_ad = address_document::get_my_address_document().
143
+ my_identity = my_ad $identity.
108
144
  invite is invite_t = (
109
- $invite_id -> invite_id,
110
- $inviter_id -> _get_container_id(),
111
- $inviter_name -> my_name,
112
- // Embed my address document so the joiner can store it and re-register
113
- // me after a code upgrade (see peer_ads / import_state).
114
- $inviter_ad -> address_document::get_my_address_document()
145
+ $d -> invite_id,
146
+ $n -> my_name,
147
+ $c -> my_identity $container_id,
148
+ $k -> my_identity $key_list,
149
+ $a -> my_ad $authorizations
115
150
  ).
116
151
 
117
152
  return transaction::success [
@@ -129,17 +164,41 @@ application actor loads libraries
129
164
  current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
130
165
 
131
166
  invite = (_read_or_abort invite_blob) safe invite_t.
132
- inviter_id = invite $inviter_id.
167
+ inviter_id = invite $c.
133
168
  abort "This invite is your own — you cannot add yourself." when inviter_id == _get_container_id().
134
169
 
170
+ // Rebuild default_keys from the carried public keys: each key reports its
171
+ // own function and id, so this reproduces the inviter's default-key map
172
+ // exactly. With it we reconstruct the full identity (and hence its
173
+ // _value_id, which the self-signatures sign over) byte-for-byte, then the
174
+ // full address document. import_state later replays this reconstructed
175
+ // document through process_address_document to re-register the inviter's
176
+ // keys after a code upgrade — so it must validate, and it does.
177
+ inviter_keys = invite $k.
178
+ inviter_default_keys is (key_utils::t_function ->> key_utils::t_key_id) = (,).
179
+ sc inviter_keys -- (key -> )
180
+ {
181
+ inviter_default_keys (key_utils::key_get_function key) -> (_crypto_get_key_id key).
182
+ }
183
+ inviter_identity is key_storage::t_container_identity = (
184
+ $key_list -> inviter_keys,
185
+ $default_keys -> inviter_default_keys,
186
+ $container_id -> inviter_id
187
+ ).
188
+ inviter_ad is address_document_types::t_address_document = (
189
+ $version -> 1,
190
+ $identity -> inviter_identity,
191
+ $authorizations -> invite $a
192
+ ).
193
+
135
194
  // Register the inviter under my chosen name, or the name they embedded.
136
- contact_name = (custom_name == NIL ?? (invite $inviter_name) ; custom_name?).
195
+ contact_name = (custom_name == NIL ?? (invite $n) ; custom_name?).
137
196
  contacts inviter_id -> ($name -> contact_name, $container_id -> inviter_id).
138
197
  // Remember the inviter's address document so I can re-register them after
139
198
  // an upgrade (their keys are seed-stable, so this stays valid).
140
- peer_ads inviter_id -> invite $inviter_ad.
199
+ peer_ads inviter_id -> inviter_ad.
141
200
 
142
- invite_id = invite $invite_id.
201
+ invite_id = invite $d.
143
202
  my_self_name = my_name.
144
203
  my_ad = address_document::get_my_address_document().
145
204
 
@@ -187,6 +246,117 @@ application actor loads libraries
187
246
  return inbox.
188
247
  }
189
248
 
249
+ // Hand the agent every message it has not seen yet (status "unread") and
250
+ // flip those to "read". This is the ONLY place message bodies leave the
251
+ // packet. Delivery is the dedup point: a message is returned by get_messages
252
+ // exactly once, so an agent that reads and acts immediately never double-
253
+ // processes — no explicit acknowledgement is required for safety. To leave a
254
+ // message for another session instead, call defer_messages (status -> unread
255
+ // again); to discard it for good, call mark_processed (removed from inbox).
256
+ trn get_messages _
257
+ {
258
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
259
+
260
+ fresh is message_t[] = [].
261
+ new_inbox is message_t[] = [].
262
+ sc inbox -- ( -> m)
263
+ {
264
+ if (m $status) == "unread"
265
+ {
266
+ read_m is message_t = (
267
+ $msg_id -> m $msg_id,
268
+ $sender_id -> m $sender_id,
269
+ $sender_name -> m $sender_name,
270
+ $text -> m $text,
271
+ $date -> m $date,
272
+ $status -> "read"
273
+ ).
274
+ fresh (_count fresh|) -> m.
275
+ new_inbox (_count new_inbox|) -> read_m.
276
+ }
277
+ else
278
+ {
279
+ new_inbox (_count new_inbox|) -> m.
280
+ }
281
+ }
282
+ inbox -> new_inbox.
283
+
284
+ return transaction::success [
285
+ _return_data ($messages -> fresh),
286
+ _save_state NIL
287
+ ].
288
+ }
289
+
290
+ // Remove the given messages from the inbox permanently — the agent is done
291
+ // with them. Idempotent: ids that are already gone are silently skipped.
292
+ trn mark_processed _:($msg_ids -> ids: int[])
293
+ {
294
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
295
+
296
+ wanted is (int ->> bool) = (,).
297
+ sc ids -- ( -> id) { wanted id -> TRUE. }
298
+
299
+ new_inbox is message_t[] = [].
300
+ removed is int = 0.
301
+ sc inbox -- ( -> m)
302
+ {
303
+ if wanted (m $msg_id)
304
+ {
305
+ removed -> removed + 1.
306
+ }
307
+ else
308
+ {
309
+ new_inbox (_count new_inbox|) -> m.
310
+ }
311
+ }
312
+ inbox -> new_inbox.
313
+
314
+ return transaction::success [
315
+ _return_data ($processed -> removed),
316
+ _save_state NIL
317
+ ].
318
+ }
319
+
320
+ // Put already-read messages back into the queue (status -> "unread") so a
321
+ // different session will pick them up on its next get_messages. The explicit,
322
+ // opt-in counterpart to the safe default: forgetting to defer just means the
323
+ // message stays handled by you; only a deliberate defer re-exposes it.
324
+ trn defer_messages _:($msg_ids -> ids: int[])
325
+ {
326
+ current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
327
+
328
+ wanted is (int ->> bool) = (,).
329
+ sc ids -- ( -> id) { wanted id -> TRUE. }
330
+
331
+ new_inbox is message_t[] = [].
332
+ deferred is int = 0.
333
+ sc inbox -- ( -> m)
334
+ {
335
+ if (wanted (m $msg_id)) && ((m $status) == "read")
336
+ {
337
+ new_inbox (_count new_inbox|) -> (
338
+ $msg_id -> m $msg_id,
339
+ $sender_id -> m $sender_id,
340
+ $sender_name -> m $sender_name,
341
+ $text -> m $text,
342
+ $date -> m $date,
343
+ $status -> "unread"
344
+ ).
345
+ deferred -> deferred + 1.
346
+ }
347
+ else
348
+ {
349
+ new_inbox (_count new_inbox|) -> m.
350
+ }
351
+ }
352
+ inbox -> new_inbox.
353
+
354
+ return transaction::success [
355
+ _return_data ($deferred -> deferred),
356
+ _save_state NIL
357
+ ].
358
+ }
359
+
190
360
  // ---- upgrade: state export / import -------------------------------------
191
361
  // The host persists state by calling export_state (readonly) and serializing
192
362
  // the returned value to a code-independent blob. On a code upgrade it recreates
@@ -203,6 +373,7 @@ application actor loads libraries
203
373
  $contacts -> contacts,
204
374
  $pending_invites -> pending_invites,
205
375
  $inbox -> inbox,
376
+ $next_msg_seq -> next_msg_seq,
206
377
  $peer_ads -> peer_ads
207
378
  ).
208
379
  }
@@ -211,19 +382,44 @@ application actor loads libraries
211
382
  {
212
383
  current_transaction_info::validate_origin_or_abort (transaction::envelope::origin::user,).
213
384
 
214
- d = data safe (
215
- $my_name -> str,
216
- $contacts -> (global_id ->> contact_t),
217
- $pending_invites -> (global_id ->> str),
218
- $inbox -> message_t[],
219
- $peer_ads -> (global_id ->> address_document_types::t_address_document)
220
- ).
221
-
222
- my_name -> d $my_name.
223
- contacts -> d $contacts.
224
- pending_invites -> d $pending_invites.
225
- inbox -> d $inbox.
226
- peer_ads -> d $peer_ads.
385
+ // The fields that did not change across the schema bump are validated the
386
+ // same way for any version of the blob.
387
+ my_name -> (data $my_name) safe str.
388
+ contacts -> (data $contacts) safe (global_id ->> contact_t).
389
+ pending_invites -> (data $pending_invites) safe (global_id ->> str).
390
+ peer_ads -> (data $peer_ads) safe (global_id ->> address_document_types::t_address_document).
391
+
392
+ // The inbox + next_msg_seq are the only parts the message-lifecycle change
393
+ // touched. A pre-lifecycle blob has no $next_msg_seq and inbox entries with
394
+ // no id/status — MIGRATE it forward (the whole point of code-independent
395
+ // state is that an old export upgrades, never resets): assign each legacy
396
+ // message a sequential id and status "unread", and seed next_msg_seq past
397
+ // them. A current blob is taken as-is.
398
+ if (data $next_msg_seq) == NIL
399
+ {
400
+ legacy_inbox = (data $inbox) safe (legacy_message_t[]).
401
+ migrated is message_t[] = [].
402
+ seq is int = 1.
403
+ sc legacy_inbox -- ( -> m)
404
+ {
405
+ migrated (_count migrated|) -> (
406
+ $msg_id -> seq,
407
+ $sender_id -> m $sender_id,
408
+ $sender_name -> m $sender_name,
409
+ $text -> m $text,
410
+ $date -> m $date,
411
+ $status -> "unread"
412
+ ).
413
+ seq -> seq + 1.
414
+ }
415
+ inbox -> migrated.
416
+ next_msg_seq -> seq.
417
+ }
418
+ else
419
+ {
420
+ inbox -> (data $inbox) safe (message_t[]).
421
+ next_msg_seq -> (data $next_msg_seq) safe int.
422
+ }
227
423
 
228
424
  // Re-register every peer's keys so encrypted channels keep working after
229
425
  // the upgrade — no handshake needed (my own keys are unchanged, and the
@@ -276,15 +472,23 @@ application actor loads libraries
276
472
  sender_name = sender? $name.
277
473
  msg_date = current_transaction_info::get_transaction_time().
278
474
 
475
+ mid = next_msg_seq.
476
+ next_msg_seq -> next_msg_seq + 1.
477
+
279
478
  inbox (_count inbox|) -> (
479
+ $msg_id -> mid,
280
480
  $sender_id -> sender_id,
281
481
  $sender_name -> sender_name,
282
482
  $text -> text,
283
- $date -> msg_date
483
+ $date -> msg_date,
484
+ $status -> "unread"
284
485
  ).
285
486
 
487
+ // The notification deliberately carries NO message body — only that a
488
+ // message arrived, from whom, and its id. The body stays in the packet
489
+ // and only leaves through get_messages.
286
490
  return transaction::success [
287
- _notify_agent ($event -> $message_received, $sender_name -> sender_name, $text -> text, $date -> msg_date),
491
+ _notify_agent ($event -> $message_received, $sender_name -> sender_name, $msg_id -> mid, $date -> msg_date),
288
492
  _save_state NIL
289
493
  ].
290
494
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adapt-toolkit/a2adapt",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "MCP server daemon for a2adapt — one native ADAPT wrapper hosting N self-sovereign identities, exposing secure agent-to-agent messaging tools over HTTP (Streamable HTTP). Run `a2adapt-mcp start`.",
5
5
  "type": "module",
6
6
  "license": "MIT",