@nelsonlongxiang/dsh-open-a2a-net 0.5.14 → 0.6.24

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 (86) hide show
  1. package/README.md +243 -121
  2. package/README_zh.md +144 -84
  3. package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
  4. package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
  5. package/assets/nexusDist/index.html +26 -0
  6. package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
  7. package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
  8. package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
  9. package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
  10. package/assets/stageDist/index.html +13 -0
  11. package/lib/a2a-client.js +190 -13
  12. package/lib/canvas-store.js +218 -0
  13. package/lib/card.js +25 -0
  14. package/lib/client/A2aControl.js +45 -137
  15. package/lib/client/locales.js +16 -40
  16. package/lib/client.js +164 -467
  17. package/lib/client.js.map +1 -1
  18. package/lib/group-store.js +6 -0
  19. package/lib/idempotency-store.js +291 -0
  20. package/lib/index.js +4217 -259
  21. package/lib/joined-store.js +11 -0
  22. package/lib/layout-store.js +155 -0
  23. package/lib/peer-store.js +98 -13
  24. package/lib/receipt-ladder.js +35 -0
  25. package/lib/receipt.js +102 -0
  26. package/lib/self-suppress.js +62 -0
  27. package/lib/session-transcript.js +150 -0
  28. package/lib/skill/SKILL.md +77 -0
  29. package/lib/skill/references/discovery.md +38 -0
  30. package/lib/skill/references/membership.md +50 -0
  31. package/lib/skill/references/pitfalls.md +42 -0
  32. package/lib/skill/references/routing.md +43 -0
  33. package/lib/skill-registration.js +52 -0
  34. package/lib/stage-mount.js +32 -0
  35. package/lib/task-ledger.js +540 -0
  36. package/lib/team-store.js +122 -0
  37. package/lib/teams-bridge.js +18 -0
  38. package/lib/transport-caps.js +31 -0
  39. package/lib/types/a2a-client.d.ts +72 -1
  40. package/lib/types/canvas-store.d.ts +102 -0
  41. package/lib/types/client/A2aControl.d.ts +18 -28
  42. package/lib/types/client/locales.d.ts +1 -1
  43. package/lib/types/group-store.d.ts +6 -0
  44. package/lib/types/idempotency-store.d.ts +164 -0
  45. package/lib/types/index.d.ts +210 -6
  46. package/lib/types/joined-store.d.ts +9 -0
  47. package/lib/types/layout-store.d.ts +63 -0
  48. package/lib/types/peer-store.d.ts +45 -2
  49. package/lib/types/receipt-ladder.d.ts +25 -0
  50. package/lib/types/receipt.d.ts +51 -0
  51. package/lib/types/self-suppress.d.ts +41 -0
  52. package/lib/types/session-transcript.d.ts +37 -0
  53. package/lib/types/skill-registration.d.ts +3 -0
  54. package/lib/types/stage-mount.d.ts +29 -0
  55. package/lib/types/task-ledger.d.ts +308 -0
  56. package/lib/types/team-store.d.ts +55 -0
  57. package/lib/types/teams-bridge.d.ts +112 -0
  58. package/lib/types/transport-caps.d.ts +29 -0
  59. package/lib/types/types.d.ts +43 -0
  60. package/nexus-stage/package-lock.json +2262 -0
  61. package/nexus-stage/package.json +21 -0
  62. package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
  63. package/nexus-stage/scripts/mockhost.py +253 -0
  64. package/nexus-stage/src/canvas-ops.ts +175 -0
  65. package/nexus-stage/src/canvas-wire.ts +156 -0
  66. package/nexus-stage/src/edges.ts +72 -0
  67. package/nexus-stage/src/fault.ts +56 -0
  68. package/nexus-stage/src/federation.ts +234 -0
  69. package/nexus-stage/src/layout-doc.ts +143 -0
  70. package/nexus-stage/src/layout-wire.ts +158 -0
  71. package/nexus-stage/src/main.ts +361 -0
  72. package/nexus-stage/src/planning-menus.ts +352 -0
  73. package/nexus-stage/src/planning-nodelist.ts +294 -0
  74. package/nexus-stage/src/planning-types.ts +111 -0
  75. package/nexus-stage/src/planning-view.ts +1313 -0
  76. package/nexus-stage/src/planning.css +280 -0
  77. package/nexus-stage/src/seat.ts +35 -0
  78. package/nexus-stage/src/stage-name.ts +13 -0
  79. package/nexus-stage/src/tokens.ts +32 -0
  80. package/nexus-stage/src/viewport.ts +83 -0
  81. package/nexus-stage/src/workbench.css +267 -0
  82. package/nexus-stage/src/workbench.ts +1530 -0
  83. package/nexus-stage/src/world.ts +509 -0
  84. package/nexus-stage/tsconfig.json +25 -0
  85. package/nexus-stage/vite.config.ts +16 -0
  86. package/package.json +83 -22
@@ -67,6 +67,17 @@ export class JoinedSessions {
67
67
  this.ids = this.ids.filter(entry => entry !== id);
68
68
  this.persist();
69
69
  }
70
+ /**
71
+ * F7 (wake-intent self-heal): re-read the persisted snapshot into memory.
72
+ * A boot-time transient (e.g. the workspace registry briefly reporting a
73
+ * session archived) can prune the in-memory intent while the file still
74
+ * holds it — the file is the durable source of the user's gesture, so the
75
+ * reload restores memory to match it. Callers use this as a one-shot
76
+ * fallback when a wake finds no in-memory match.
77
+ */
78
+ reload() {
79
+ this.restore();
80
+ }
70
81
  /** Load a persisted snapshot on construction, if present. */
71
82
  restore() {
72
83
  if (this.path === '' || !existsSync(this.path))
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Persisted spatial layout for the infinite-canvas page ('a2a/canvas-layout.json').
3
+ * Pure presentation state: node coordinates, team-frame rectangles, and the
4
+ * viewport transform. Membership truth stays in canvas.json - this store may
5
+ * be reset freely without touching who sits in which team.
6
+ *
7
+ * Unit semantics (contract note 6, 2026-08-28 ruling): {x,y} are 2D
8
+ * planning-canvas card centers in pixels. Other consumers (the 3D
9
+ * observation lens) must reproject into their own envelope, never consume
10
+ * the values as absolute scene coordinates.
11
+ * @module @nelsonlongxiang/dsh-open-a2a-net/layout-store
12
+ */
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ /** World-coordinate magnitude bound: values beyond are clamped, never trusted. */
16
+ export const COORD_LIMIT = 1_000_000;
17
+ /** Zoom bounds, matching the page's wheel clamps. */
18
+ export const SCALE_MIN = 0.25;
19
+ export const SCALE_MAX = 3;
20
+ /** Document shape caps: a layout taxonomy, not a database. */
21
+ export const LAYOUT_NODE_CAP = 256;
22
+ export const LAYOUT_FRAME_CAP = 96;
23
+ function clampCoord(v) {
24
+ const n = typeof v === 'number' ? v : Number.NaN;
25
+ if (!Number.isFinite(n))
26
+ return undefined;
27
+ return Math.max(-COORD_LIMIT, Math.min(COORD_LIMIT, Math.round(n)));
28
+ }
29
+ function clampPoint(raw) {
30
+ if (raw === null || typeof raw !== 'object')
31
+ return undefined;
32
+ const rec = raw;
33
+ const x = clampCoord(rec.x);
34
+ const y = clampCoord(rec.y);
35
+ return x === undefined || y === undefined ? undefined : { x, y };
36
+ }
37
+ function clampRect(raw) {
38
+ const point = clampPoint(raw);
39
+ if (point === undefined)
40
+ return undefined;
41
+ const rec = raw;
42
+ const w = clampCoord(rec.w);
43
+ const h = clampCoord(rec.h);
44
+ if (w === undefined || h === undefined || w <= 0 || h <= 0)
45
+ return undefined;
46
+ return { ...point, w: Math.min(w, COORD_LIMIT), h: Math.min(h, COORD_LIMIT) };
47
+ }
48
+ function clampDoc(raw) {
49
+ if (raw === null || typeof raw !== 'object')
50
+ return undefined;
51
+ const rec = raw;
52
+ if (rec.version !== 1)
53
+ return undefined;
54
+ // The viewport is a point+scale, not a rect - validate its own shape.
55
+ const vpRaw = rec.viewport;
56
+ if (vpRaw === null || typeof vpRaw !== 'object')
57
+ return undefined;
58
+ const vp = vpRaw;
59
+ const vx = clampCoord(vp.x);
60
+ const vy = clampCoord(vp.y);
61
+ const vsRaw = vp.scale;
62
+ const vsNum = typeof vsRaw === 'number' ? vsRaw : Number.NaN;
63
+ if (vx === undefined || vy === undefined || !Number.isFinite(vsNum))
64
+ return undefined;
65
+ const viewport = { x: vx, y: vy };
66
+ const scale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, vsNum));
67
+ const nodes = {};
68
+ if (rec.nodes !== null && typeof rec.nodes === 'object') {
69
+ for (const [id, point] of Object.entries(rec.nodes)) {
70
+ if (typeof id !== 'string' || id === '' || id.length > 128)
71
+ continue;
72
+ const p = clampPoint(point);
73
+ if (p !== undefined)
74
+ nodes[id] = p;
75
+ if (Object.keys(nodes).length >= LAYOUT_NODE_CAP)
76
+ break;
77
+ }
78
+ }
79
+ const frames = {};
80
+ if (rec.frames !== null && typeof rec.frames === 'object') {
81
+ for (const [name, rect] of Object.entries(rec.frames)) {
82
+ // Frame keys mirror canvas team names: same discipline (no '/', cap 40).
83
+ if (typeof name !== 'string' || name === '' || name.includes('/') || name.length > 40)
84
+ continue;
85
+ const r = clampRect(rect);
86
+ if (r !== undefined)
87
+ frames[name] = r;
88
+ if (Object.keys(frames).length >= LAYOUT_FRAME_CAP)
89
+ break;
90
+ }
91
+ }
92
+ return { version: 1, viewport: { x: viewport.x, y: viewport.y, scale }, nodes, frames };
93
+ }
94
+ /**
95
+ * Whole-document layout store with last-write-wins persistence. Accepts
96
+ * anything the client sends and answers with the normalized truth: clients
97
+ * round-trip GET after save when they need canonical numbers.
98
+ */
99
+ export class LayoutStore {
100
+ doc;
101
+ path;
102
+ /** @param path - the snapshot file; an empty path keeps memory-only. */
103
+ constructor(path) {
104
+ this.path = path;
105
+ this.restore();
106
+ }
107
+ restore() {
108
+ if (this.path === '' || !existsSync(this.path))
109
+ return;
110
+ try {
111
+ this.doc = clampDoc(JSON.parse(readFileSync(this.path, 'utf8')));
112
+ }
113
+ catch {
114
+ this.doc = undefined; // corrupt layout is presentation loss only
115
+ }
116
+ }
117
+ persist() {
118
+ if (this.path === '')
119
+ return;
120
+ try {
121
+ mkdirSync(dirname(this.path), { recursive: true });
122
+ writeFileSync(this.path, JSON.stringify(this.doc), { mode: 0o600 });
123
+ }
124
+ catch {
125
+ // An unwritable home degrades to memory-only presentation state.
126
+ }
127
+ }
128
+ /** The current snapshot, or null before the first save. */
129
+ get() {
130
+ return this.doc ?? null;
131
+ }
132
+ /**
133
+ * Replace the document wholesale after normalization.
134
+ * @returns false when the payload is not a version-1 layout document.
135
+ */
136
+ save(raw) {
137
+ const doc = clampDoc(raw);
138
+ if (doc === undefined)
139
+ return false;
140
+ this.doc = doc;
141
+ this.persist();
142
+ return true;
143
+ }
144
+ /** Drop all persisted presentation state. */
145
+ reset() {
146
+ this.doc = undefined;
147
+ if (this.path === '')
148
+ return;
149
+ try {
150
+ mkdirSync(dirname(this.path), { recursive: true });
151
+ writeFileSync(this.path, '', { mode: 0o600 });
152
+ }
153
+ catch { /* degrade to memory-only */ }
154
+ }
155
+ }
package/lib/peer-store.js CHANGED
@@ -8,7 +8,8 @@
8
8
  * .agents/notes/proposed/feature/2026-08-16-gnunet-prior-art-for-a2a-topology.md.
9
9
  * @module @nelsonlongxiang/dsh-open-a2a-net/peer-store
10
10
  */
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { existsSync, readFileSync } from 'node:fs';
12
+ import { mkdir, writeFile } from 'node:fs/promises';
12
13
  import { dirname } from 'node:path';
13
14
  /** Hard cap on tracked peers, seeds included (hostlist bound: 30). */
14
15
  export const PEER_CAP = 30;
@@ -29,15 +30,17 @@ export const EVICTION_FLOOR = 0;
29
30
  export class PeerStore {
30
31
  path;
31
32
  cap;
33
+ persistDebounceMs;
32
34
  peers = new Map();
33
35
  /**
34
36
  * @param seeds - the configured seed URLs; always retained (never evicted).
35
37
  * @param path - persistence file (`<dsh-home>/a2a/peers.json`); empty = no persistence.
36
38
  * @param cap - peer bound, seeds included (default {@link PEER_CAP}).
37
39
  */
38
- constructor(seeds, path, cap = PEER_CAP) {
40
+ constructor(seeds, path, cap = PEER_CAP, persistDebounceMs = 1_000) {
39
41
  this.path = path;
40
42
  this.cap = cap;
43
+ this.persistDebounceMs = persistDebounceMs;
41
44
  this.restore();
42
45
  for (const seed of seeds) {
43
46
  if (seed === '')
@@ -59,6 +62,15 @@ export class PeerStore {
59
62
  .sort((a, b) => (a.seed === b.seed ? b.score - a.score : a.seed ? -1 : 1))
60
63
  .map(peer => peer.url);
61
64
  }
65
+ /**
66
+ * The operator-established subset (gh#8 N9): URLs that came from the
67
+ * config seed set — never the referral-learned tail. Team-name resolution
68
+ * rides this basis so a self-naming host nobody linked cannot capture
69
+ * routes; probing and visibility keep the full list.
70
+ */
71
+ listSeeded() {
72
+ return [...this.peers.values()].filter(peer => peer.seed).map(peer => peer.url);
73
+ }
62
74
  /** Whether the store has room for one more peer. */
63
75
  hasRoom() {
64
76
  return this.peers.size < this.cap;
@@ -127,6 +139,38 @@ export class PeerStore {
127
139
  score(url) {
128
140
  return this.peers.get(url)?.score;
129
141
  }
142
+ /**
143
+ * Record the signed-card session a tracked peer served. The session is the
144
+ * node identity (the same fact the self-referral guard judges), so learning
145
+ * it per URL is what lets the caller collapse alias spellings of one node
146
+ * (loopback vs LAN IP) into a single tracked peer.
147
+ * @param url - the tracked peer the card came from.
148
+ * @param session - the verified card's session label; empty is ignored.
149
+ */
150
+ learnSession(url, session) {
151
+ const peer = this.peers.get(url);
152
+ if (peer === undefined || session === '')
153
+ return;
154
+ // PeerRecord fields are readonly: replace the record wholesale.
155
+ this.peers.set(url, { ...peer, session });
156
+ this.persist();
157
+ }
158
+ /**
159
+ * The strongest tracked URL known to serve one node identity — seeds beat
160
+ * referrals, then the higher score wins (ties keep first-tracked order).
161
+ * @param session - the signed-card session to look up.
162
+ * @returns the winning peer URL, or undefined when none is tracked.
163
+ */
164
+ bySession(session) {
165
+ let best;
166
+ for (const peer of this.peers.values()) {
167
+ if (peer.session !== session)
168
+ continue;
169
+ if (best === undefined || (best.seed === peer.seed ? peer.score > best.score : peer.seed))
170
+ best = peer;
171
+ }
172
+ return best?.url;
173
+ }
130
174
  /** Load a persisted snapshot on construction, if present. */
131
175
  restore() {
132
176
  if (this.path === '' || !existsSync(this.path))
@@ -144,6 +188,7 @@ export class PeerStore {
144
188
  url: peer.url,
145
189
  score: typeof peer.score === 'number' && Number.isFinite(peer.score) ? peer.score : INITIAL_SCORE,
146
190
  seed: peer.seed === true,
191
+ ...(typeof peer.session === 'string' && peer.session !== '' ? { session: peer.session } : {}),
147
192
  });
148
193
  }
149
194
  }
@@ -151,19 +196,59 @@ export class PeerStore {
151
196
  // A corrupt store is not fatal: fall back to seeds only.
152
197
  }
153
198
  }
154
- /** Persist the current peer set (no-op when no path was configured). */
199
+ /**
200
+ * Persist the current peer set (no-op when no path was configured).
201
+ * Writes are debounced and asynchronous: a sweep that settles dozens of
202
+ * fetch outcomes must not fire one synchronous writeFileSync per change
203
+ * (each blocks the event loop for milliseconds on Windows). The dirty
204
+ * flag coalesces the whole window into one async write; `flush()` lets
205
+ * tests and teardown await the final state on disk. A crash inside the
206
+ * window loses at most `persistDebounceMs` of score deltas — seeds are
207
+ * re-seeded from config on every boot, and scores rebuild on contact.
208
+ */
209
+ dirty = false;
210
+ writeChain = Promise.resolve();
211
+ pendingTimer;
155
212
  persist() {
156
213
  if (this.path === '')
157
214
  return;
158
- try {
159
- mkdirSync(dirname(this.path), { recursive: true });
160
- const snapshot = {
161
- peers: [...this.peers.values()].map(peer => ({ ...peer })),
162
- };
163
- writeFileSync(this.path, JSON.stringify(snapshot), { mode: 0o600 });
164
- }
165
- catch {
166
- // An unwritable home must not break routing; the store degrades to memory-only.
167
- }
215
+ this.dirty = true;
216
+ // Classic trailing debounce: one shared timer, reset on every change, so
217
+ // a steady drip of outcomes still lands as one write after the last
218
+ // change — not one write per elapsed window. Unref'd so a timer armed at
219
+ // teardown never keeps the process alive for it.
220
+ if (this.pendingTimer !== undefined)
221
+ clearTimeout(this.pendingTimer);
222
+ const timer = setTimeout(() => {
223
+ this.pendingTimer = undefined;
224
+ if (!this.dirty)
225
+ return;
226
+ this.dirty = false;
227
+ this.writeChain = this.writeChain.then(async () => {
228
+ if (this.path === '')
229
+ return;
230
+ try {
231
+ await mkdir(dirname(this.path), { recursive: true });
232
+ const snapshot = {
233
+ peers: [...this.peers.values()].map(peer => ({ ...peer })),
234
+ };
235
+ await writeFile(this.path, JSON.stringify(snapshot), { mode: 0o600 });
236
+ }
237
+ catch {
238
+ // An unwritable home must not break routing; the store degrades to memory-only.
239
+ }
240
+ });
241
+ }, this.persistDebounceMs);
242
+ timer.unref?.();
243
+ this.pendingTimer = timer;
244
+ }
245
+ /**
246
+ * Await the last scheduled write (tests and teardown). Resolves when the
247
+ * debounced write chain has settled with the current in-memory state.
248
+ */
249
+ async flush() {
250
+ if (this.dirty)
251
+ await new Promise(resolve => setTimeout(resolve, this.persistDebounceMs + 5));
252
+ await this.writeChain;
168
253
  }
169
254
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Receipt delivery three-tier hard order (work-order P2 / B1 residual):
3
+ * caller lane first; if it fails, escalate ONCE to the owner mailbox so the
4
+ * outcome still lands somewhere answerable on this host; total failure stays
5
+ * a breadcrumb (correlation & archive remain the authoritative truth).
6
+ *
7
+ * Extracted pure so the ladder is unit-drivable without live agents.
8
+ * @module @nelsonlongxiang/dsh-open-a2a-net/receipt-ladder
9
+ */
10
+ /**
11
+ * Attempt delivery through the ladder.
12
+ * @param callerTeam - tier-one target (`callbackTarget`). When it already IS
13
+ * the owner team, a failure terminates after tier one — escalating to
14
+ * yourself would just double the noise.
15
+ */
16
+ export async function runReceiptLadder(deps, callerTeam) {
17
+ try {
18
+ await deps.deliver(callerTeam);
19
+ return;
20
+ }
21
+ catch (error) {
22
+ deps.log(`to ${callerTeam}`, error);
23
+ if (callerTeam === deps.ownerTeam)
24
+ throw error;
25
+ }
26
+ try {
27
+ await deps.deliver(deps.ownerTeam);
28
+ return;
29
+ }
30
+ catch (error) {
31
+ deps.log(`to owner ${deps.ownerTeam}`, error);
32
+ deps.log('both lanes', error);
33
+ throw error;
34
+ }
35
+ }
package/lib/receipt.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Receipt text codec — single home for the correlation pattern and the v2
3
+ * structured envelope (receipt-envelope-v2.md). The header line
4
+ * `[A2A receipt] task <id> …` stays byte-identical to the network's original
5
+ * contract; the machine payload rides EXACTLY ONE following line that must be
6
+ * a complete JSON object containing at least one known key. Everything after
7
+ * it is human continuation and every consumer ignores unknown keys.
8
+ *
9
+ * Discriminator over a bare `{`: an object qualifies as an envelope only when
10
+ * one of the known keys is present, so legacy prose summaries that happen to
11
+ * open with a brace can never be mistaken for machine payloads.
12
+ * @module @nelsonlongxiang/dsh-open-a2a-net/receipt
13
+ */
14
+ /** The network's fixed, case-sensitive correlation template. */
15
+ export const RECEIPT_PATTERN = /^\[A2A receipt\] task (\S+)\s*([\s\S]*)$/;
16
+ /** Controlled outcome vocabulary — the ONLY branchable field of an envelope. */
17
+ export const RECEIPT_OUTCOMES = [
18
+ 'completed',
19
+ 'failed',
20
+ 'abandoned',
21
+ 'expired',
22
+ 'rejected',
23
+ 'sync_completed',
24
+ 'unknown',
25
+ ];
26
+ const KNOWN_ENVELOPE_KEYS = ['outcome', 'idempotencyKey', 'sessionKey', 'elapsedMs', 'artifacts'];
27
+ void KNOWN_ENVELOPE_KEYS;
28
+ function tryEnvelopeLine(line) {
29
+ const trimmed = line.trim();
30
+ if (!trimmed.startsWith('{'))
31
+ return undefined;
32
+ try {
33
+ const value = JSON.parse(trimmed);
34
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
35
+ return undefined;
36
+ const record = value;
37
+ // Discriminator: a known key must EXTRACT at least one valid field —
38
+ // an object carrying only garbage values stays human prose (v1).
39
+ const fields = {};
40
+ if (typeof record['outcome'] === 'string' && RECEIPT_OUTCOMES.includes(record['outcome'])) {
41
+ fields.outcome = record['outcome'];
42
+ }
43
+ if (typeof record['idempotencyKey'] === 'string')
44
+ fields.idempotencyKey = record['idempotencyKey'];
45
+ if (typeof record['sessionKey'] === 'string')
46
+ fields.sessionKey = record['sessionKey'];
47
+ if (typeof record['elapsedMs'] === 'number' && Number.isFinite(record['elapsedMs']))
48
+ fields.elapsedMs = record['elapsedMs'];
49
+ if (Object.keys(fields).length === 0)
50
+ return undefined;
51
+ return fields;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ /**
58
+ * Parse one inbound message against the receipt contract.
59
+ * @returns `null` when the message is not a receipt at all; otherwise the
60
+ * correlation key with its summary and, when present, the v2 envelope.
61
+ */
62
+ export function parseReceipt(message) {
63
+ const match = RECEIPT_PATTERN.exec(message);
64
+ if (match === null)
65
+ return null;
66
+ const taskId = match[1] ?? '';
67
+ const rest = match[2] ?? '';
68
+ const newlineAt = rest.indexOf('\n');
69
+ const firstRestLine = newlineAt === -1 ? '' : rest.slice(0, newlineAt);
70
+ const envelope = newlineAt !== -1
71
+ ? tryEnvelopeLine(rest.slice(newlineAt + 1).split('\n', 1)[0] ?? '')
72
+ : undefined;
73
+ // Without a machine line the legacy remainder flows through untouched so
74
+ // multi-line human summaries keep their exact historical shape.
75
+ const summary = envelope !== undefined ? firstRestLine : rest;
76
+ return { taskId, summary, ...(envelope !== undefined ? { envelope } : {}) };
77
+ }
78
+ /**
79
+ * Compose a receipt text: the immutable header (+ human summary) followed,
80
+ * when any envelope field is supplied, by exactly one complete JSON line.
81
+ * @param taskId - correlation key echoed in the header.
82
+ * @param summary - human-readable outcome tail on the header line.
83
+ * @param fields - structured projection; omit entirely for a pure v1 receipt.
84
+ */
85
+ export function formatReceipt(taskId, summary, fields) {
86
+ const header = `[A2A receipt] task ${taskId}${summary !== '' ? ` ${summary}` : ''}`;
87
+ if (fields === undefined)
88
+ return header;
89
+ const ordered = {};
90
+ if (fields.outcome !== undefined)
91
+ ordered['outcome'] = fields.outcome;
92
+ if (fields.idempotencyKey !== undefined)
93
+ ordered['idempotencyKey'] = fields.idempotencyKey;
94
+ if (fields.sessionKey !== undefined)
95
+ ordered['sessionKey'] = fields.sessionKey;
96
+ if (fields.elapsedMs !== undefined)
97
+ ordered['elapsedMs'] = fields.elapsedMs;
98
+ const keys = Object.keys(ordered);
99
+ if (keys.length === 0)
100
+ return header;
101
+ return `${header}\n${JSON.stringify(ordered)}`;
102
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Unmasked-identity suppression: decentralized gossip mirrors peer lists back
3
+ * and forth, so every alias of this node's own address comes home as an
4
+ * inbound referral within minutes of being dropped. Fetching one is the
5
+ * identity check that drops it (the fetched card carries our session id),
6
+ * but the next inbound referral re-offers the same URL seconds later, and
7
+ * the peer store flickers between present and dropped forever.
8
+ *
9
+ * The same flicker plagues alias spellings of OTHER tracked nodes: a peer
10
+ * reached as both loopback and LAN IP drops one spelling on the alias
11
+ * collapse, and the next mirrored referral re-offers it. This filter
12
+ * therefore remembers each URL whose fetch revealed a settled identity —
13
+ * self or an already-tracked peer — for a window far beyond any realistic
14
+ * offer cadence: offers inside the window are refused without a network
15
+ * round-trip. Shape-agnostic on purpose — alias spellings never seen before
16
+ * (localhost vs 127.0.0.1 vs a hostname) suppress naturally once their first
17
+ * fetch unmasks them.
18
+ * @module @nelsonlongxiang/dsh-open-a2a-net/self-suppress
19
+ */
20
+ /** How long an identity-revealing URL stays suppressed after its unmasking fetch. */
21
+ export const SELF_REFERRAL_SUPPRESS_MS = 6 * 60 * 60_000;
22
+ /** Bounded memory of URLs whose recent fetch proved they serve a known identity. */
23
+ export class SelfReferralFilter {
24
+ remembered = new Map();
25
+ now;
26
+ /** @param now - injectable clock for deterministic tests. */
27
+ constructor(now = () => Date.now()) {
28
+ this.now = now;
29
+ }
30
+ /**
31
+ * Record that fetching `url` served this node's own card. Expired entries
32
+ * prune first, so the map stays bounded by the live offer cadence.
33
+ */
34
+ remember(url) {
35
+ if (url === '')
36
+ return;
37
+ this.prune(this.now());
38
+ this.remembered.set(url, this.now());
39
+ }
40
+ /**
41
+ * Whether one inbound referral is still worth offering to the store.
42
+ * A URL unmasked as self inside the suppression window refuses without a
43
+ * fetch; every other URL passes through unchanged.
44
+ */
45
+ shouldOffer(url) {
46
+ const at = this.remembered.get(url);
47
+ if (at === undefined)
48
+ return true;
49
+ if (this.now() - at > SELF_REFERRAL_SUPPRESS_MS) {
50
+ this.remembered.delete(url);
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ /** Drop entries past the window (called from the mutating entry points). */
56
+ prune(at) {
57
+ for (const [url, since] of this.remembered) {
58
+ if (at - since > SELF_REFERRAL_SUPPRESS_MS)
59
+ this.remembered.delete(url);
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Session transcript reader (0.6.11): lets the workbench inspector show the
3
+ * tail of a LOCAL session's own conversation log, inline, with no navigation
4
+ * (owner ruling 2026-10-20: no jumps, responsive only). Remote nodes' logs
5
+ * live on their machines — this module deliberately only reads this host's
6
+ * <dshHome>/sessions tree.
7
+ *
8
+ * Log layout (dsh session-persistence-jsonl): each session dir holds one
9
+ * append-only JSONL artifact per FORMAT GENERATION — session.jsonl.zstd,
10
+ * session.v2.jsonl.zstd, session.v3.jsonl.zstd… (generation, not rotation:
11
+ * a migration writes the next file and new events append there). The zstd
12
+ * artifacts are CONCATENATED independent frames — the writer appends one
13
+ * self-contained checksummed frame per durable batch, and node:zlib's public
14
+ * decoder stops after the first frame, so a reader must split frames
15
+ * structurally and decode each with zstdDecompressSync.
16
+ *
17
+ * Event records (JSONL lines) this extractor cares about:
18
+ * {"type":"user/message","seq","time","data":{"content":[{"type":"text","text"}]}}
19
+ * {"type":"assistant/message","seq","time","data":{"message":{"role":"assistant","content":[{"type":"text"|"reasoning"|"tool-call",…}]}}}
20
+ * reasoning and tool-call segments are skipped; only human-visible text is a
21
+ * transcript entry.
22
+ */
23
+ import { zstdDecompressSync } from 'node:zlib';
24
+ /** Hard caps so a pathological log cannot balloon memory or the response. */
25
+ export const TRANSCRIPT_MAX_FILE_BYTES = 64 * 1024 * 1024;
26
+ export const TRANSCRIPT_MAX_DECOMPRESSED_BYTES = 160 * 1024 * 1024;
27
+ export const TRANSCRIPT_ENTRY_TEXT_CAP = 500;
28
+ export const TRANSCRIPT_DEFAULT_LIMIT = 20;
29
+ export const TRANSCRIPT_MAX_LIMIT = 100;
30
+ const ZSTD_MAGIC = 0xfd2fb528;
31
+ /**
32
+ * Walk concatenated zstd frames structurally (no block decoding) and return
33
+ * their byte ranges. Malformed structure rejects; EOF inside a final frame is
34
+ * dropped from the result (a torn tail frame decodes to nothing usable).
35
+ */
36
+ export function scanZstdFrames(buffer) {
37
+ const frames = [];
38
+ let offset = 0;
39
+ while (offset + 4 <= buffer.length) {
40
+ if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC)
41
+ break;
42
+ const start = offset;
43
+ offset += 4;
44
+ if (offset >= buffer.length)
45
+ break;
46
+ const descriptor = buffer.readUInt8(offset);
47
+ offset += 1;
48
+ const contentSizeFlag = descriptor >>> 6;
49
+ const singleSegment = (descriptor & 0x20) !== 0;
50
+ const checksum = (descriptor & 0x04) !== 0;
51
+ const dictionaryFlag = descriptor & 0x03;
52
+ const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
53
+ const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag;
54
+ offset += (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
55
+ let complete = false;
56
+ while (offset + 3 <= buffer.length) {
57
+ const blockHeader = buffer.readUIntLE(offset, 3);
58
+ offset += 3;
59
+ const lastBlock = (blockHeader & 1) !== 0;
60
+ const blockType = (blockHeader >>> 1) & 0x03;
61
+ const blockSize = blockHeader >>> 3;
62
+ if (blockType === 0x03)
63
+ return frames; // reserved block type: stop before it
64
+ const payloadBytes = blockType === 0x01 ? 1 : blockSize;
65
+ if (offset + payloadBytes > buffer.length)
66
+ break;
67
+ offset += payloadBytes;
68
+ if (lastBlock) {
69
+ complete = true;
70
+ break;
71
+ }
72
+ }
73
+ if (!complete)
74
+ break;
75
+ if (checksum)
76
+ offset += 4;
77
+ if (offset > buffer.length)
78
+ break;
79
+ frames.push({ start, end: offset });
80
+ }
81
+ return frames;
82
+ }
83
+ /**
84
+ * Decode a session log artifact to text. Plain .jsonl passes through; .zstd
85
+ * artifacts are split into frames and decoded frame by frame (node's public
86
+ * decoder is single-frame).
87
+ */
88
+ export function decodeSessionLog(buffer) {
89
+ const frames = scanZstdFrames(buffer);
90
+ if (frames.length === 0)
91
+ return buffer.toString('utf8');
92
+ const parts = [];
93
+ let total = 0;
94
+ for (const frame of frames) {
95
+ const chunk = zstdDecompressSync(buffer.subarray(frame.start, frame.end));
96
+ total += chunk.length;
97
+ if (total > TRANSCRIPT_MAX_DECOMPRESSED_BYTES) {
98
+ throw new Error('session log exceeds the decompressed-size cap');
99
+ }
100
+ parts.push(chunk.toString('utf8'));
101
+ }
102
+ return parts.join('');
103
+ }
104
+ function clampEntry(text) {
105
+ const flat = text.replace(/\s+/g, ' ').trim();
106
+ return flat.length > TRANSCRIPT_ENTRY_TEXT_CAP ? flat.slice(0, TRANSCRIPT_ENTRY_TEXT_CAP) + '…' : flat;
107
+ }
108
+ /**
109
+ * Extract the last `limit` human-visible turns from a decoded session log.
110
+ * User lines come from user/message data.content[].text; assistant lines from
111
+ * assistant/message data.message.content[] segments of type "text" (reasoning
112
+ * and tool calls are working detail, not transcript).
113
+ */
114
+ export function extractTranscript(logText, limit = TRANSCRIPT_DEFAULT_LIMIT) {
115
+ const entries = [];
116
+ for (const line of logText.split('\n')) {
117
+ if (line.trim() === '')
118
+ continue;
119
+ let record;
120
+ try {
121
+ record = JSON.parse(line);
122
+ }
123
+ catch {
124
+ continue;
125
+ }
126
+ if (typeof record !== 'object' || record === null)
127
+ continue;
128
+ const event = record;
129
+ const time = typeof event.time === 'number' ? event.time : undefined;
130
+ if (event.type === 'user/message') {
131
+ const data = event.data;
132
+ const text = (data?.content ?? [])
133
+ .filter(seg => seg?.type === 'text' && typeof seg.text === 'string')
134
+ .map(seg => seg.text)
135
+ .join(' ');
136
+ if (text.trim() !== '')
137
+ entries.push({ role: 'user', text: clampEntry(text), time });
138
+ }
139
+ else if (event.type === 'assistant/message') {
140
+ const data = event.data;
141
+ const text = (data?.message?.content ?? [])
142
+ .filter(seg => seg?.type === 'text' && typeof seg.text === 'string')
143
+ .map(seg => seg.text)
144
+ .join(' ');
145
+ if (text.trim() !== '')
146
+ entries.push({ role: 'assistant', text: clampEntry(text), time });
147
+ }
148
+ }
149
+ return entries.slice(-limit);
150
+ }