@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.
- package/README.md +243 -121
- package/README_zh.md +144 -84
- package/assets/nexusDist/assets/index-034h1cNc.js +3 -0
- package/assets/nexusDist/assets/index-PG9EieGX.css +1 -0
- package/assets/nexusDist/index.html +26 -0
- package/assets/stageDist/assets/index-Bj7S2pA1.css +1 -0
- package/assets/stageDist/assets/index-C-28k_o5.js +974 -0
- package/assets/stageDist/assets/interiors-CQGBjffT.png +0 -0
- package/assets/stageDist/assets/office-tileset-CHQT-uYL.png +0 -0
- package/assets/stageDist/index.html +13 -0
- package/lib/a2a-client.js +190 -13
- package/lib/canvas-store.js +218 -0
- package/lib/card.js +25 -0
- package/lib/client/A2aControl.js +45 -137
- package/lib/client/locales.js +16 -40
- package/lib/client.js +164 -467
- package/lib/client.js.map +1 -1
- package/lib/group-store.js +6 -0
- package/lib/idempotency-store.js +291 -0
- package/lib/index.js +4217 -259
- package/lib/joined-store.js +11 -0
- package/lib/layout-store.js +155 -0
- package/lib/peer-store.js +98 -13
- package/lib/receipt-ladder.js +35 -0
- package/lib/receipt.js +102 -0
- package/lib/self-suppress.js +62 -0
- package/lib/session-transcript.js +150 -0
- package/lib/skill/SKILL.md +77 -0
- package/lib/skill/references/discovery.md +38 -0
- package/lib/skill/references/membership.md +50 -0
- package/lib/skill/references/pitfalls.md +42 -0
- package/lib/skill/references/routing.md +43 -0
- package/lib/skill-registration.js +52 -0
- package/lib/stage-mount.js +32 -0
- package/lib/task-ledger.js +540 -0
- package/lib/team-store.js +122 -0
- package/lib/teams-bridge.js +18 -0
- package/lib/transport-caps.js +31 -0
- package/lib/types/a2a-client.d.ts +72 -1
- package/lib/types/canvas-store.d.ts +102 -0
- package/lib/types/client/A2aControl.d.ts +18 -28
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/group-store.d.ts +6 -0
- package/lib/types/idempotency-store.d.ts +164 -0
- package/lib/types/index.d.ts +210 -6
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/layout-store.d.ts +63 -0
- package/lib/types/peer-store.d.ts +45 -2
- package/lib/types/receipt-ladder.d.ts +25 -0
- package/lib/types/receipt.d.ts +51 -0
- package/lib/types/self-suppress.d.ts +41 -0
- package/lib/types/session-transcript.d.ts +37 -0
- package/lib/types/skill-registration.d.ts +3 -0
- package/lib/types/stage-mount.d.ts +29 -0
- package/lib/types/task-ledger.d.ts +308 -0
- package/lib/types/team-store.d.ts +55 -0
- package/lib/types/teams-bridge.d.ts +112 -0
- package/lib/types/transport-caps.d.ts +29 -0
- package/lib/types/types.d.ts +43 -0
- package/nexus-stage/package-lock.json +2262 -0
- package/nexus-stage/package.json +21 -0
- package/nexus-stage/scripts/__pycache__/mockhost.cpython-312.pyc +0 -0
- package/nexus-stage/scripts/mockhost.py +253 -0
- package/nexus-stage/src/canvas-ops.ts +175 -0
- package/nexus-stage/src/canvas-wire.ts +156 -0
- package/nexus-stage/src/edges.ts +72 -0
- package/nexus-stage/src/fault.ts +56 -0
- package/nexus-stage/src/federation.ts +234 -0
- package/nexus-stage/src/layout-doc.ts +143 -0
- package/nexus-stage/src/layout-wire.ts +158 -0
- package/nexus-stage/src/main.ts +361 -0
- package/nexus-stage/src/planning-menus.ts +352 -0
- package/nexus-stage/src/planning-nodelist.ts +294 -0
- package/nexus-stage/src/planning-types.ts +111 -0
- package/nexus-stage/src/planning-view.ts +1313 -0
- package/nexus-stage/src/planning.css +280 -0
- package/nexus-stage/src/seat.ts +35 -0
- package/nexus-stage/src/stage-name.ts +13 -0
- package/nexus-stage/src/tokens.ts +32 -0
- package/nexus-stage/src/viewport.ts +83 -0
- package/nexus-stage/src/workbench.css +267 -0
- package/nexus-stage/src/workbench.ts +1530 -0
- package/nexus-stage/src/world.ts +509 -0
- package/nexus-stage/tsconfig.json +25 -0
- package/nexus-stage/vite.config.ts +16 -0
- package/package.json +83 -22
package/lib/joined-store.js
CHANGED
|
@@ -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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
+
}
|