agents-city 0.5.2 → 0.5.4
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/.claude-plugin/marketplace.json +1 -1
- package/README.es.md +11 -0
- package/README.md +10 -0
- package/benchmarks/latency/fake-claude-cli.mjs +3 -2
- package/bin/serve.py +63 -6
- package/bin/test +2 -2
- package/bin/test-alcance.py +470 -0
- package/bin/test-arnes.py +109 -0
- package/bin/test-channel.py +295 -9
- package/bin/test-cities.py +57 -0
- package/bin/test-claude-runtime.py +17 -6
- package/bin/test-connect-client.mjs +8 -0
- package/bin/test-connect.py +1 -1
- package/bin/test-contracts.py +129 -3
- package/bin/test-e2e.py +371 -0
- package/bin/test-seat.py +154 -1
- package/bin/test-serve.py +83 -0
- package/city/web/dist-hall/hall.js +384 -3240
- package/docs/managed-connect.md +5 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/channel/hub/lo-que-te-llega.ts +78 -0
- package/plugin/channel/hub/local-roads.ts +36 -0
- package/plugin/channel/hub/road-controller.ts +30 -5
- package/plugin/channel/local-hub.js +177 -105
- package/plugin/channel/local-hub.ts +8 -0
- package/plugin/channel/managed-connect-client.js +53 -3
- package/plugin/channel/managed-connect-client.manifest.json +2 -2
- package/plugin/channel/protocol.ts +24 -0
- package/plugin/commands/notice.md +14 -0
- package/plugin/hooks/ask-the-house.sh +25 -0
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/hooks/notice-on-stop.sh +7 -1
- package/plugin/scripts/alcance.py +318 -0
- package/plugin/scripts/arnes.py +10 -1
- package/plugin/scripts/busca.py +13 -0
- package/plugin/scripts/card.py +40 -0
- package/plugin/scripts/cities.py +46 -4
- package/plugin/scripts/city-session.sh +80 -7
- package/plugin/scripts/domains.py +7 -0
- package/plugin/scripts/seat.py +33 -13
- package/plugin/skills/city/SKILL.md +12 -0
package/docs/managed-connect.md
CHANGED
|
@@ -163,6 +163,11 @@ consuming another capability. A changed body with the same ID is rejected.
|
|
|
163
163
|
were already admitted. Neither status means that a person or model has read or
|
|
164
164
|
answered the message.
|
|
165
165
|
|
|
166
|
+
If an identified WebSocket admission temporarily returns `delivery_unavailable`
|
|
167
|
+
or `mailbox_full`, the client makes at most two more attempts with the same
|
|
168
|
+
encrypted envelope. Server-directed delays are capped and receive per-message
|
|
169
|
+
jitter, so a recovering lane is not hit again by every sender at once.
|
|
170
|
+
|
|
166
171
|
Transport is therefore at-least-once with an idempotent local effect, not a
|
|
167
172
|
fictional exactly-once network. Revocation removes the Road revision, queued
|
|
168
173
|
ciphertext, capabilities and local cryptographic state. Reopening consumes new
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "city",
|
|
3
3
|
"displayName": "Agents City",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.4",
|
|
5
5
|
"description": "One autonomous city seat with a work domain, role, goal, repo support agents, recognised skills and explicit roads to other cities.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "jlcases",
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a city says reaches its seat — read from that city's own role file.
|
|
3
|
+
*
|
|
4
|
+
* Knowing a `ux` is on the bus does not tell you whether what you changed
|
|
5
|
+
* concerns them. The answer to that is written in their `roles/<role>.md`,
|
|
6
|
+
* under "What reaches you" — and until now that file never left the city that
|
|
7
|
+
* owned it. Each seat judged against its own copy of a catalogue, which is a
|
|
8
|
+
* guess wearing a document's clothes.
|
|
9
|
+
*
|
|
10
|
+
* So it travels, with three rules it cannot break:
|
|
11
|
+
*
|
|
12
|
+
* · The city that OWNS the role publishes it. Nobody describes somebody else.
|
|
13
|
+
* · It is bounded before it is published and again before it is read. Two
|
|
14
|
+
* processes, two chances to be wrong, and only one of them is ours.
|
|
15
|
+
* · It is a CLAIM, never an instruction. It ends up in another seat's model
|
|
16
|
+
* context, so it is neutralised the way road messages are.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { stripSpecialTokens } from '../untrusted.js';
|
|
22
|
+
|
|
23
|
+
/** Long enough for a sentence or two of remit, short enough that nobody can
|
|
24
|
+
* paste a document into somebody else's context. */
|
|
25
|
+
export const LIMITE = 400;
|
|
26
|
+
|
|
27
|
+
/** The heading that names it, in the format the shipped role files use. */
|
|
28
|
+
const SECCION = /^##\s+What reaches you\s*$/i;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The "What reaches you" section of a role file, as one bounded line.
|
|
32
|
+
*
|
|
33
|
+
* Returns '' for anything it cannot read confidently — a missing file, a role
|
|
34
|
+
* with no such section, a section that is empty. Silence is the honest answer:
|
|
35
|
+
* "this role did not say" is a fact a reader can act on, and an invented
|
|
36
|
+
* summary is not.
|
|
37
|
+
*/
|
|
38
|
+
export function loQueLlega(dataDir: string, seatRole: string): string {
|
|
39
|
+
if (!/^[a-z0-9-]{1,80}$/.test(seatRole)) return '';
|
|
40
|
+
let texto: string;
|
|
41
|
+
try {
|
|
42
|
+
texto = readFileSync(join(dataDir, 'roles', `${seatRole}.md`), 'utf8');
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
return limpia(seccion(texto));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The body under the heading, up to the next heading or the end. */
|
|
50
|
+
function seccion(texto: string): string {
|
|
51
|
+
const lineas = texto.split('\n');
|
|
52
|
+
const desde = lineas.findIndex((l) => SECCION.test(l));
|
|
53
|
+
if (desde < 0) return '';
|
|
54
|
+
const cuerpo: string[] = [];
|
|
55
|
+
for (const linea of lineas.slice(desde + 1)) {
|
|
56
|
+
if (/^##\s/.test(linea)) break;
|
|
57
|
+
cuerpo.push(linea);
|
|
58
|
+
}
|
|
59
|
+
return cuerpo.join(' ');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One line, bounded, with chat-template delimiters defanged and control
|
|
64
|
+
* characters removed.
|
|
65
|
+
*
|
|
66
|
+
* The neutralising happens on the way OUT as well as on the way in. A city
|
|
67
|
+
* publishes this about itself, so a city with a tampered role file would
|
|
68
|
+
* otherwise be handing a forged turn to every neighbour that reads it — and
|
|
69
|
+
* "the reader will sanitise it" is not a property this side gets to assume.
|
|
70
|
+
*/
|
|
71
|
+
export function limpia(bruto: string): string {
|
|
72
|
+
const plano = stripSpecialTokens(String(bruto ?? ''))
|
|
73
|
+
.replace(/[\u0000-\u001f\u007f]/g, ' ')
|
|
74
|
+
.replace(/\s+/g, ' ')
|
|
75
|
+
.trim();
|
|
76
|
+
if (!plano) return '';
|
|
77
|
+
return plano.length <= LIMITE ? plano : plano.slice(0, LIMITE - 1) + '…';
|
|
78
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import WebSocket from 'ws';
|
|
4
|
+
import { limpia as limpiaRemite } from './lo-que-te-llega.js';
|
|
4
5
|
import { CityContext, runtimeDirForCity } from '../city-config.js';
|
|
5
6
|
import { queueRoad } from '../delivery-queue.js';
|
|
6
7
|
import { BusEnvelope, HubEndpoint, Road, randomId } from '../protocol.js';
|
|
@@ -30,6 +31,41 @@ export async function sendLocalRoad(
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* What the city at the far end says it is, read from its own endpoint file.
|
|
36
|
+
*
|
|
37
|
+
* The same file `localRoadOnline` already trusts to decide the city is alive.
|
|
38
|
+
* Nothing new is trusted here: if that file is good enough to say "this city
|
|
39
|
+
* exists and is running", it is good enough to say "and it is the design one".
|
|
40
|
+
*/
|
|
41
|
+
export function localRoadPresenta(
|
|
42
|
+
context: CityContext,
|
|
43
|
+
road: Road,
|
|
44
|
+
): { domain: string; seatRole: string; recibe: string } | null {
|
|
45
|
+
try {
|
|
46
|
+
const endpoint = JSON.parse(
|
|
47
|
+
readFileSync(join(runtimeDirForCity(context.appHome, road.id), 'endpoint.json'), 'utf8'),
|
|
48
|
+
) as HubEndpoint;
|
|
49
|
+
if (endpoint.cityId !== road.id || endpoint.cityAddress !== road.address) return null;
|
|
50
|
+
const dicho = endpoint.presenta;
|
|
51
|
+
if (!dicho || typeof dicho.seatRole !== 'string' || typeof dicho.domain !== 'string')
|
|
52
|
+
return null;
|
|
53
|
+
// Untrusted text from another process: bounded and shaped before it is
|
|
54
|
+
// handed to anybody who might put it in a prompt.
|
|
55
|
+
const limpio = (valor: string): string =>
|
|
56
|
+
/^[a-z0-9-]{1,80}$/.test(valor.trim().toLowerCase()) ? valor.trim().toLowerCase() : '';
|
|
57
|
+
const seatRole = limpio(dicho.seatRole);
|
|
58
|
+
const domain = limpio(dicho.domain);
|
|
59
|
+
// Bounded again on the way in. The publisher bounds it too, and that is
|
|
60
|
+
// the point: the writer is another process, and a reader that trusts a
|
|
61
|
+
// length because the writer promised one is trusting the wrong side.
|
|
62
|
+
const recibe = limpiaRemite(typeof dicho.recibe === 'string' ? dicho.recibe : '');
|
|
63
|
+
return seatRole || domain ? { domain, seatRole, recibe } : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
export function localRoadOnline(context: CityContext, road: Road): boolean {
|
|
34
70
|
try {
|
|
35
71
|
const endpoint = JSON.parse(
|
|
@@ -12,7 +12,7 @@ import { requireChair } from '../committee/guards.js';
|
|
|
12
12
|
import { deliverApprovedReception, recordReceptionMessage } from '../reception.js';
|
|
13
13
|
import { wrapUntrusted } from '../untrusted.js';
|
|
14
14
|
import { EnvelopeRouter } from './envelopes.js';
|
|
15
|
-
import { localRoadOnline, sendLocalRoad } from './local-roads.js';
|
|
15
|
+
import { localRoadOnline, localRoadPresenta, sendLocalRoad } from './local-roads.js';
|
|
16
16
|
import { remoteRoadBridge } from './remote-roads.js';
|
|
17
17
|
import { managedReceptionBridge } from '../managed-connect/reception-bridge.js';
|
|
18
18
|
|
|
@@ -132,10 +132,35 @@ export function roadController(context: CityContext, router: EnvelopeRouter) {
|
|
|
132
132
|
): Promise<unknown> => {
|
|
133
133
|
if (name === 'road.roster') {
|
|
134
134
|
requireChair(actor, role);
|
|
135
|
-
return roads().map((road) =>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
return roads().map((road) => {
|
|
136
|
+
const online = road.local ? localRoadOnline(context, road) : remote.online(road.address);
|
|
137
|
+
// What the far city says about itself wins over what whoever opened
|
|
138
|
+
// this road assumed — and the entry says which of the two you are
|
|
139
|
+
// reading, because "cpto because they say so" and "cpto because I
|
|
140
|
+
// wrote it down once" are not the same claim, and only one of them is
|
|
141
|
+
// safe to act on.
|
|
142
|
+
const dicho = road.local && online ? localRoadPresenta(context, road) : null;
|
|
143
|
+
// Per field, not per entry. A city can publish a good domain and a role
|
|
144
|
+
// that is not a role — and then one `segun` for the whole row would say
|
|
145
|
+
// "the city itself" over a role the city never validly claimed. Whoever
|
|
146
|
+
// is deciding "does this reach that role" needs to know whether THE
|
|
147
|
+
// ROLE is confirmed, and the domain's provenance does not answer that.
|
|
148
|
+
const suRol = dicho?.seatRole ?? '';
|
|
149
|
+
const suDominio = dicho?.domain ?? '';
|
|
150
|
+
const nota = 'this city’s own note';
|
|
151
|
+
const ella = 'the city itself';
|
|
152
|
+
return {
|
|
153
|
+
...road,
|
|
154
|
+
...(suRol ? { role: suRol } : {}),
|
|
155
|
+
...(suDominio ? { domain: suDominio } : {}),
|
|
156
|
+
// What that city says reaches it. A claim, in their words, and the
|
|
157
|
+
// only thing here that turns "a ux is connected" into "this concerns
|
|
158
|
+
// them". Absent when they did not say.
|
|
159
|
+
...(dicho?.recibe ? { recibe: dicho.recibe } : {}),
|
|
160
|
+
online,
|
|
161
|
+
segun: { role: suRol ? ella : nota, domain: suDominio ? ella : nota },
|
|
162
|
+
};
|
|
163
|
+
});
|
|
139
164
|
}
|
|
140
165
|
if (name === 'road.inbox') {
|
|
141
166
|
requireChair(actor, role);
|