@aeondave/pi-persona 1.15.2
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/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent four-character Exocom join aliases.
|
|
3
|
+
*
|
|
4
|
+
* The alias is presentation-only: the durable value is the existing 24-hex scope id. Alias
|
|
5
|
+
* files are intentionally kept out of the registry directory because this allocator owns a
|
|
6
|
+
* different invariant: once a code has been assigned, it is never assigned to another scope.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
closeSync,
|
|
11
|
+
constants as fsConstants,
|
|
12
|
+
fstatSync,
|
|
13
|
+
fsyncSync,
|
|
14
|
+
linkSync,
|
|
15
|
+
lstatSync,
|
|
16
|
+
mkdirSync,
|
|
17
|
+
openSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
|
|
24
|
+
export const JOIN_CODE_LENGTH = 4;
|
|
25
|
+
export const JOIN_CODE_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
|
26
|
+
export const MAX_JOIN_CODE_ATTEMPTS = 256;
|
|
27
|
+
const MAPPING_MAX_BYTES = 4 * 1024;
|
|
28
|
+
const CODES_DIRECTORY = "codes";
|
|
29
|
+
const USED_DIRECTORY = ".used";
|
|
30
|
+
const MAPPING_VERSION = 1;
|
|
31
|
+
const JOIN_CODE_PATTERN = /^[0-9A-Za-z]{4}$/;
|
|
32
|
+
const SCOPE_ID_PATTERN = /^[0-9a-f]{24}$/i;
|
|
33
|
+
|
|
34
|
+
type Mapping = { code: string; scope_id: string; version: number };
|
|
35
|
+
type Reservation = { code: string; scope_id: string; version: number };
|
|
36
|
+
type ReadState<T> = { kind: "missing" } | { kind: "invalid" } | { kind: "valid"; value: T };
|
|
37
|
+
|
|
38
|
+
/** Return true only for the public, case-sensitive four-character Base62 grammar. */
|
|
39
|
+
export function isJoinCode(value: unknown): value is string {
|
|
40
|
+
return typeof value === "string" && JOIN_CODE_PATTERN.test(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Validate and return a join code; callers that accept untrusted input should catch this error. */
|
|
44
|
+
export function validateJoinCode(value: unknown): string {
|
|
45
|
+
if (!isJoinCode(value)) throw new TypeError("join code must be exactly four Base62 characters");
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizeScopeId(value: unknown): string {
|
|
50
|
+
if (typeof value !== "string" || !SCOPE_ID_PATTERN.test(value)) {
|
|
51
|
+
throw new TypeError("scope id must be exactly 24-hex characters");
|
|
52
|
+
}
|
|
53
|
+
return value.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function codesDirectory(agentDir: string): string {
|
|
57
|
+
return join(agentDir, "persona", "exocom", CODES_DIRECTORY);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function usedDirectory(agentDir: string): string {
|
|
61
|
+
return join(codesDirectory(agentDir), USED_DIRECTORY);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Encode each ASCII byte so a mixed-case alias is never used as a case-folding filesystem name. */
|
|
65
|
+
export function encodeJoinCode(code: string): string {
|
|
66
|
+
return Buffer.from(validateJoinCode(code), "ascii").toString("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function joinCodePath(agentDir: string, code: string): string {
|
|
70
|
+
return join(codesDirectory(agentDir), `${encodeJoinCode(code)}.json`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function joinCodeReservationPath(agentDir: string, code: string): string {
|
|
74
|
+
return join(usedDirectory(agentDir), `${encodeJoinCode(code)}.json`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function candidateBytes(scopeId: string, attempt: number): Buffer {
|
|
78
|
+
return createHash("sha256")
|
|
79
|
+
.update("pi-persona-exocom-join-code-v1\0", "utf8")
|
|
80
|
+
.update(scopeId, "utf8")
|
|
81
|
+
.update("\0", "utf8")
|
|
82
|
+
.update(String(attempt), "utf8")
|
|
83
|
+
.digest();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Generate the deterministic candidate for one bounded allocation attempt. */
|
|
87
|
+
export function candidateJoinCode(scopeId: string, attempt = 0): string {
|
|
88
|
+
const normalizedScope = normalizeScopeId(scopeId);
|
|
89
|
+
if (!Number.isInteger(attempt) || attempt < 0 || attempt >= MAX_JOIN_CODE_ATTEMPTS) {
|
|
90
|
+
throw new RangeError(`join-code attempt must be an integer from 0 to ${MAX_JOIN_CODE_ATTEMPTS - 1}`);
|
|
91
|
+
}
|
|
92
|
+
let value = BigInt(`0x${candidateBytes(normalizedScope, attempt).toString("hex")}`);
|
|
93
|
+
let code = "";
|
|
94
|
+
for (let i = 0; i < JOIN_CODE_LENGTH; i++) {
|
|
95
|
+
const index = Number(value % BigInt(JOIN_CODE_ALPHABET.length));
|
|
96
|
+
code = `${JOIN_CODE_ALPHABET[index]!}${code}`;
|
|
97
|
+
value /= BigInt(JOIN_CODE_ALPHABET.length);
|
|
98
|
+
}
|
|
99
|
+
return code;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function ensureDirectory(path: string): void {
|
|
103
|
+
try {
|
|
104
|
+
const existing = lstatSync(path);
|
|
105
|
+
if (!existing.isDirectory() || existing.isSymbolicLink()) throw new Error(`unsafe join-code directory: ${path}`);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
108
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
109
|
+
const created = lstatSync(path);
|
|
110
|
+
if (!created.isDirectory() || created.isSymbolicLink()) throw new Error(`unsafe join-code directory: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readJson<T>(path: string, parse: (value: unknown) => T | undefined): ReadState<T> {
|
|
115
|
+
let stat;
|
|
116
|
+
try {
|
|
117
|
+
stat = lstatSync(path);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
120
|
+
return { kind: "invalid" };
|
|
121
|
+
}
|
|
122
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.size > MAPPING_MAX_BYTES) return { kind: "invalid" };
|
|
123
|
+
const noFollow = (fsConstants as typeof fsConstants & { O_NOFOLLOW?: number }).O_NOFOLLOW ?? 0;
|
|
124
|
+
let fd: number | undefined;
|
|
125
|
+
try {
|
|
126
|
+
fd = openSync(path, fsConstants.O_RDONLY | noFollow);
|
|
127
|
+
const opened = fstatSync(fd);
|
|
128
|
+
if (!opened.isFile() || opened.size > MAPPING_MAX_BYTES) return { kind: "invalid" };
|
|
129
|
+
const parsed: unknown = JSON.parse(readFileSync(fd, { encoding: "utf8" }));
|
|
130
|
+
const value = parse(parsed);
|
|
131
|
+
return value === undefined ? { kind: "invalid" } : { kind: "valid", value };
|
|
132
|
+
} catch {
|
|
133
|
+
return { kind: "invalid" };
|
|
134
|
+
} finally {
|
|
135
|
+
if (fd !== undefined) closeSync(fd);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseMapping(expectedCode: string): (value: unknown) => Mapping | undefined {
|
|
140
|
+
return (value) => {
|
|
141
|
+
if (!value || typeof value !== "object") return undefined;
|
|
142
|
+
const candidate = value as Record<string, unknown>;
|
|
143
|
+
if (candidate.version !== MAPPING_VERSION || candidate.code !== expectedCode) return undefined;
|
|
144
|
+
if (typeof candidate.scope_id !== "string" || !SCOPE_ID_PATTERN.test(candidate.scope_id)) return undefined;
|
|
145
|
+
return { version: MAPPING_VERSION, code: expectedCode, scope_id: candidate.scope_id.toLowerCase() };
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function reservationState(agentDir: string, code: string): ReadState<Reservation> {
|
|
150
|
+
return readJson(joinCodeReservationPath(agentDir, code), (value) => {
|
|
151
|
+
if (!value || typeof value !== "object") return undefined;
|
|
152
|
+
const candidate = value as Record<string, unknown>;
|
|
153
|
+
if (candidate.version !== MAPPING_VERSION || candidate.code !== code) return undefined;
|
|
154
|
+
if (typeof candidate.scope_id !== "string" || !SCOPE_ID_PATTERN.test(candidate.scope_id)) return undefined;
|
|
155
|
+
return { version: MAPPING_VERSION, code, scope_id: candidate.scope_id.toLowerCase() };
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Publish complete JSON at an unused path. A hard-link makes the fully written inode visible in one
|
|
160
|
+
* step, avoiding the open-before-write window where another Pi could observe an empty reservation. */
|
|
161
|
+
function publishJsonExclusive(path: string, value: unknown): boolean {
|
|
162
|
+
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
163
|
+
let fd: number | undefined;
|
|
164
|
+
try {
|
|
165
|
+
fd = openSync(tempPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
|
|
166
|
+
writeFileSync(fd, `${JSON.stringify(value)}\n`, { encoding: "utf8" });
|
|
167
|
+
fsyncSync(fd);
|
|
168
|
+
} finally {
|
|
169
|
+
if (fd !== undefined) closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
linkSync(tempPath, path);
|
|
173
|
+
return true;
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
176
|
+
throw error;
|
|
177
|
+
} finally {
|
|
178
|
+
try { unlinkSync(tempPath); } catch { /* a crash-equivalent orphan is harmless */ }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Reserve a code with an atomic exclusive publish; a failed/invalid reservation remains occupied. */
|
|
183
|
+
function reserveCode(agentDir: string, code: string, scopeId: string): boolean {
|
|
184
|
+
return publishJsonExclusive(
|
|
185
|
+
joinCodeReservationPath(agentDir, code),
|
|
186
|
+
{ version: MAPPING_VERSION, code, scope_id: scopeId },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function mappingState(agentDir: string, code: string): ReadState<Mapping> {
|
|
191
|
+
return readJson(joinCodePath(agentDir, code), parseMapping(code));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Create a mapping once, preserving a raced/existing path rather than overwriting it. */
|
|
195
|
+
function createMapping(agentDir: string, code: string, scopeId: string): boolean {
|
|
196
|
+
return publishJsonExclusive(
|
|
197
|
+
joinCodePath(agentDir, code),
|
|
198
|
+
{ version: MAPPING_VERSION, code, scope_id: scopeId },
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Complete a same-scope reservation that won the race before its mapping was written. */
|
|
203
|
+
function recoverReservedCode(agentDir: string, code: string, scopeId: string): boolean {
|
|
204
|
+
const reserved = reservationState(agentDir, code);
|
|
205
|
+
if (reserved.kind !== "valid" || reserved.value.scope_id !== scopeId) return false;
|
|
206
|
+
const existing = mappingState(agentDir, code);
|
|
207
|
+
if (existing.kind === "valid") return existing.value.scope_id === scopeId;
|
|
208
|
+
if (existing.kind !== "missing") return false;
|
|
209
|
+
if (createMapping(agentDir, code, scopeId)) return true;
|
|
210
|
+
const raced = mappingState(agentDir, code);
|
|
211
|
+
return raced.kind === "valid" && raced.value.scope_id === scopeId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Allocate (or recover) the persistent alias for a full scope id.
|
|
216
|
+
*
|
|
217
|
+
* Existing files and reservations are never overwritten. Every collision advances to the next
|
|
218
|
+
* candidate derived from the same scope id, and reservations are retained even if mapping write
|
|
219
|
+
* fails so a code cannot later be recycled for another scope.
|
|
220
|
+
*/
|
|
221
|
+
export function allocateJoinCode(agentDir: string, scopeId: string, options: { maxAttempts?: number } = {}): string {
|
|
222
|
+
const normalizedScope = normalizeScopeId(scopeId);
|
|
223
|
+
const maxAttempts = options.maxAttempts ?? MAX_JOIN_CODE_ATTEMPTS;
|
|
224
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1 || maxAttempts > MAX_JOIN_CODE_ATTEMPTS) {
|
|
225
|
+
throw new RangeError(`maxAttempts must be an integer from 1 to ${MAX_JOIN_CODE_ATTEMPTS}`);
|
|
226
|
+
}
|
|
227
|
+
ensureDirectory(codesDirectory(agentDir));
|
|
228
|
+
ensureDirectory(usedDirectory(agentDir));
|
|
229
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
230
|
+
const code = candidateJoinCode(normalizedScope, attempt);
|
|
231
|
+
const reservation = reservationState(agentDir, code);
|
|
232
|
+
if (reservation.kind !== "missing") {
|
|
233
|
+
if (recoverReservedCode(agentDir, code, normalizedScope)) return code;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const existing = mappingState(agentDir, code);
|
|
237
|
+
if (existing.kind !== "missing") {
|
|
238
|
+
// Adopt old mappings into the tombstone set before returning or skipping them.
|
|
239
|
+
const reservationScope = existing.kind === "valid" ? existing.value.scope_id : normalizedScope;
|
|
240
|
+
if (!reserveCode(agentDir, code, reservationScope)) {
|
|
241
|
+
if (recoverReservedCode(agentDir, code, normalizedScope)) return code;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (existing.kind === "valid" && existing.value.scope_id === normalizedScope) return code;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (!reserveCode(agentDir, code, normalizedScope)) {
|
|
248
|
+
if (recoverReservedCode(agentDir, code, normalizedScope)) return code;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (recoverReservedCode(agentDir, code, normalizedScope)) return code;
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`unable to allocate an Exocom join code after ${maxAttempts} attempts`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolve a public alias to its canonical 24-hex scope id; every malformed state fails closed. */
|
|
257
|
+
export function resolveJoinCode(agentDir: string, code: unknown): string | undefined {
|
|
258
|
+
if (!isJoinCode(code)) return undefined;
|
|
259
|
+
const state = mappingState(agentDir, code);
|
|
260
|
+
return state.kind === "valid" ? state.value.scope_id : undefined;
|
|
261
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/** exocom wire envelopes + validators + truncation. Pure. */
|
|
2
|
+
export interface AgentCard { name: string; persona: string; model: string; context_pct: number; inbox: number; }
|
|
3
|
+
export interface ExocomMessage {
|
|
4
|
+
kind: "message"; msg_id: string; from_session: string; from_endpoint: string; from_name: string;
|
|
5
|
+
text: string; in_reply_to?: string; hops: number; ts: string; signature?: string;
|
|
6
|
+
}
|
|
7
|
+
/** The only structured text payload the receiver interprets specially. The marker and exact
|
|
8
|
+
* field set keep arbitrary peer-authored JSON as ordinary untrusted message text. */
|
|
9
|
+
export interface ExocomArtifactDescriptor {
|
|
10
|
+
kind: "exocom_artifact";
|
|
11
|
+
preview: string;
|
|
12
|
+
path: string;
|
|
13
|
+
size: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ExocomBye { kind: "bye"; from_session: string; from_endpoint?: string; signature?: string; }
|
|
16
|
+
export interface ExocomAck { kind: "ack"; msg_id: string; from_session?: string; signature?: string; }
|
|
17
|
+
export interface ExocomNack { kind: "nack"; msg_id: string; error: string; from_session?: string; signature?: string; }
|
|
18
|
+
|
|
19
|
+
/** Semantic collaboration frames — ack/nack stay TRANSPORT. */
|
|
20
|
+
export interface ExocomClaim {
|
|
21
|
+
kind: "claim"; work_key: string; from_session: string; from_name: string;
|
|
22
|
+
write_set: string[]; slice: string; msg_id: string; ts: string; signature?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ExocomAsk {
|
|
25
|
+
kind: "ask"; ask_id: string; work_key: string; from_session: string; from_name: string;
|
|
26
|
+
to_session: string; question: string; msg_id: string; ts: string; signature?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface ExocomAnswer {
|
|
29
|
+
kind: "answer"; ask_id: string; work_key: string; from_session: string; from_name: string;
|
|
30
|
+
ok: boolean; evidence: string; msg_id: string; ts: string; signature?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ExocomProgress {
|
|
33
|
+
kind: "progress"; work_key: string; from_session: string; from_name: string;
|
|
34
|
+
note: string; msg_id: string; ts: string; signature?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface ExocomRelease {
|
|
37
|
+
kind: "release"; work_key: string; from_session: string; from_name: string;
|
|
38
|
+
msg_id: string; ts: string; signature?: string;
|
|
39
|
+
}
|
|
40
|
+
export type ExocomSemanticFrame = ExocomClaim | ExocomAsk | ExocomAnswer | ExocomProgress | ExocomRelease;
|
|
41
|
+
export type ExocomFrame = ExocomMessage | ExocomBye | ExocomAck | ExocomNack | ExocomSemanticFrame;
|
|
42
|
+
|
|
43
|
+
const str = (v: unknown): v is string => typeof v === "string";
|
|
44
|
+
const num = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
|
|
45
|
+
const bounded = (v: unknown, max: number): v is string => str(v) && v.length > 0 && v.length <= max;
|
|
46
|
+
const token = (v: unknown): v is string => bounded(v, 128) && /^[A-Za-z0-9._:-]+$/.test(v);
|
|
47
|
+
const optionalBounded = (v: unknown, max: number): boolean => v === undefined || bounded(v, max);
|
|
48
|
+
const metadata = (v: unknown, max: number): v is string =>
|
|
49
|
+
bounded(v, max) && !/[\p{Cc}\p{Zl}\p{Zp}<>]/u.test(v);
|
|
50
|
+
const timestamp = (v: unknown): v is string =>
|
|
51
|
+
bounded(v, 128)
|
|
52
|
+
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(v)
|
|
53
|
+
&& Number.isFinite(Date.parse(v));
|
|
54
|
+
const writeSet = (v: unknown): v is string[] =>
|
|
55
|
+
Array.isArray(v) && v.length > 0 && v.length <= 64 && v.every((p) => str(p) && p.length > 0 && p.length <= 256);
|
|
56
|
+
|
|
57
|
+
export function isSemanticFrame(v: unknown): v is ExocomSemanticFrame {
|
|
58
|
+
if (!v || typeof v !== "object") return false;
|
|
59
|
+
const o = v as Record<string, unknown>;
|
|
60
|
+
switch (o.kind) {
|
|
61
|
+
case "claim":
|
|
62
|
+
return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
63
|
+
&& writeSet(o.write_set) && bounded(o.slice, 256) && token(o.msg_id) && timestamp(o.ts)
|
|
64
|
+
&& optionalBounded(o.signature, 512);
|
|
65
|
+
case "ask":
|
|
66
|
+
return token(o.ask_id) && token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
67
|
+
&& token(o.to_session) && o.to_session !== "*" && bounded(o.question, 4_096)
|
|
68
|
+
&& token(o.msg_id) && timestamp(o.ts) && optionalBounded(o.signature, 512);
|
|
69
|
+
case "answer":
|
|
70
|
+
return token(o.ask_id) && token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
71
|
+
&& typeof o.ok === "boolean" && bounded(o.evidence, 8_192)
|
|
72
|
+
&& token(o.msg_id) && timestamp(o.ts) && optionalBounded(o.signature, 512);
|
|
73
|
+
case "progress":
|
|
74
|
+
return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
75
|
+
&& bounded(o.note, 4_096) && token(o.msg_id) && timestamp(o.ts)
|
|
76
|
+
&& optionalBounded(o.signature, 512);
|
|
77
|
+
case "release":
|
|
78
|
+
return token(o.work_key) && token(o.from_session) && metadata(o.from_name, 256)
|
|
79
|
+
&& token(o.msg_id) && timestamp(o.ts) && optionalBounded(o.signature, 512);
|
|
80
|
+
default:
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Fail-closed structural validation of an inbound frame (R5). */
|
|
86
|
+
export function isExocomFrame(v: unknown): v is ExocomFrame {
|
|
87
|
+
if (!v || typeof v !== "object") return false;
|
|
88
|
+
const o = v as Record<string, unknown>;
|
|
89
|
+
switch (o.kind) {
|
|
90
|
+
case "message":
|
|
91
|
+
return token(o.msg_id) && token(o.from_session) && bounded(o.from_endpoint, 1_024) && bounded(o.from_name, 256)
|
|
92
|
+
&& str(o.text) && Number.isInteger(o.hops) && num(o.hops) && o.hops >= 0 && bounded(o.ts, 128)
|
|
93
|
+
&& (o.in_reply_to === undefined || token(o.in_reply_to)) && optionalBounded(o.signature, 512);
|
|
94
|
+
case "bye":
|
|
95
|
+
return token(o.from_session) && optionalBounded(o.from_endpoint, 1_024) && optionalBounded(o.signature, 512);
|
|
96
|
+
case "ack":
|
|
97
|
+
return token(o.msg_id) && optionalBounded(o.from_session, 128) && optionalBounded(o.signature, 512);
|
|
98
|
+
case "nack":
|
|
99
|
+
return token(o.msg_id) && bounded(o.error, 512) && optionalBounded(o.from_session, 128) && optionalBounded(o.signature, 512);
|
|
100
|
+
case "claim":
|
|
101
|
+
case "ask":
|
|
102
|
+
case "answer":
|
|
103
|
+
case "progress":
|
|
104
|
+
case "release":
|
|
105
|
+
return isSemanticFrame(v);
|
|
106
|
+
default: return false;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Stable representation signed by an exocom instance. The signature field itself is excluded. */
|
|
111
|
+
export function frameSigningPayload(frame: ExocomFrame): string {
|
|
112
|
+
switch (frame.kind) {
|
|
113
|
+
case "message":
|
|
114
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.from_session, frame.from_endpoint, frame.from_name,
|
|
115
|
+
frame.text, frame.in_reply_to ?? null, frame.hops, frame.ts]);
|
|
116
|
+
case "bye": return JSON.stringify([frame.kind, frame.from_session, frame.from_endpoint ?? null]);
|
|
117
|
+
case "ack": return JSON.stringify([frame.kind, frame.msg_id, frame.from_session ?? null]);
|
|
118
|
+
case "nack": return JSON.stringify([frame.kind, frame.msg_id, frame.error, frame.from_session ?? null]);
|
|
119
|
+
case "claim":
|
|
120
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name,
|
|
121
|
+
frame.write_set, frame.slice, frame.ts]);
|
|
122
|
+
case "ask":
|
|
123
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.ask_id, frame.work_key, frame.from_session, frame.from_name,
|
|
124
|
+
frame.to_session, frame.question, frame.ts]);
|
|
125
|
+
case "answer":
|
|
126
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.ask_id, frame.work_key, frame.from_session, frame.from_name,
|
|
127
|
+
frame.ok, frame.evidence, frame.ts]);
|
|
128
|
+
case "progress":
|
|
129
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name,
|
|
130
|
+
frame.note, frame.ts]);
|
|
131
|
+
case "release":
|
|
132
|
+
return JSON.stringify([frame.kind, frame.msg_id, frame.work_key, frame.from_session, frame.from_name, frame.ts]);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function nextHops(current: number): number { return current + 1; }
|
|
137
|
+
|
|
138
|
+
const NOTICE = "\n\n…[exocom: truncated — ask the sender for the rest]";
|
|
139
|
+
/** Head-truncate to a UTF-8 byte budget, adding a notice when cut (R1). */
|
|
140
|
+
export function truncateForInject(text: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
141
|
+
if (Buffer.byteLength(text, "utf8") <= maxBytes) return { text, truncated: false };
|
|
142
|
+
const budget = Math.max(0, Math.floor(maxBytes));
|
|
143
|
+
const chunks: string[] = [];
|
|
144
|
+
let used = 0;
|
|
145
|
+
for (const codePoint of text) {
|
|
146
|
+
const bytes = Buffer.byteLength(codePoint, "utf8");
|
|
147
|
+
if (used + bytes > budget) break;
|
|
148
|
+
chunks.push(codePoint);
|
|
149
|
+
used += bytes;
|
|
150
|
+
}
|
|
151
|
+
return { text: chunks.join("") + NOTICE, truncated: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Parse the descriptor emitted by ExocomPlane.payloadFor(). Deliberately fail closed:
|
|
156
|
+
* unmarked/arbitrary JSON remains ordinary peer text, and malformed descriptors never
|
|
157
|
+
* become an instruction to read an attacker-chosen path as an artifact.
|
|
158
|
+
*/
|
|
159
|
+
export function parseExocomArtifactDescriptor(text: string): ExocomArtifactDescriptor | undefined {
|
|
160
|
+
let value: unknown;
|
|
161
|
+
try {
|
|
162
|
+
value = JSON.parse(text);
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
167
|
+
const record = value as Record<string, unknown>;
|
|
168
|
+
const keys = Object.keys(record).sort();
|
|
169
|
+
if (keys.join(",") !== "kind,path,preview,size") return undefined;
|
|
170
|
+
if (record.kind !== "exocom_artifact" || typeof record.preview !== "string" || typeof record.path !== "string") return undefined;
|
|
171
|
+
if (record.preview.length > 4_096 || record.path.length === 0 || record.path.length > 4_096) return undefined;
|
|
172
|
+
if (/[\u0000-\u001F\u007F-\u009F]/.test(record.path)) return undefined;
|
|
173
|
+
if (typeof record.size !== "number" || !Number.isSafeInteger(record.size) || record.size <= 0) return undefined;
|
|
174
|
+
return {
|
|
175
|
+
kind: "exocom_artifact",
|
|
176
|
+
preview: record.preview,
|
|
177
|
+
path: record.path,
|
|
178
|
+
size: record.size,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Identity metadata does not claim work or settle asks. Persona denials still apply first. */
|
|
2
|
+
const ALLOWED = new Set(["exocom_name", "exocom_answer", "exocom_decline", "exocom_status", "read", "grep", "find", "ls"]);
|
|
3
|
+
|
|
4
|
+
export function constrainedTurnAllows(toolName: string): boolean {
|
|
5
|
+
return ALLOWED.has(toolName);
|
|
6
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** exocom inbound guardrails — per-sender budget (R2) and (sender,msg_id) dedup (R8). Pure. */
|
|
2
|
+
export class SenderBudget {
|
|
3
|
+
private readonly windowMs: number;
|
|
4
|
+
private readonly maxMsgs: number;
|
|
5
|
+
private readonly maxBytes: number;
|
|
6
|
+
private readonly now: () => number;
|
|
7
|
+
private readonly buckets = new Map<string, { start: number; msgs: number; bytes: number }>();
|
|
8
|
+
constructor(opts: { windowMs: number; maxMsgs: number; maxBytes: number; now?: () => number }) {
|
|
9
|
+
this.windowMs = opts.windowMs; this.maxMsgs = opts.maxMsgs; this.maxBytes = opts.maxBytes;
|
|
10
|
+
this.now = opts.now ?? Date.now;
|
|
11
|
+
}
|
|
12
|
+
allow(sender: string, bytes: number): boolean {
|
|
13
|
+
const t = this.now();
|
|
14
|
+
let b = this.buckets.get(sender);
|
|
15
|
+
if (!b || t - b.start >= this.windowMs) { b = { start: t, msgs: 0, bytes: 0 }; this.buckets.set(sender, b); }
|
|
16
|
+
if (b.msgs + 1 > this.maxMsgs || b.bytes + bytes > this.maxBytes) return false;
|
|
17
|
+
b.msgs += 1; b.bytes += bytes;
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class SeenMessages {
|
|
23
|
+
private readonly ttlMs: number;
|
|
24
|
+
private readonly now: () => number;
|
|
25
|
+
private readonly seen = new Map<string, number>(); // "sender\x1fmsg" -> firstSeenAt
|
|
26
|
+
constructor(opts: { ttlMs: number; now?: () => number }) { this.ttlMs = opts.ttlMs; this.now = opts.now ?? Date.now; }
|
|
27
|
+
seenBefore(sender: string, msgId: string): boolean {
|
|
28
|
+
const t = this.now();
|
|
29
|
+
// \x1f (US): collision-proof composite key — sender (from_session) and msgId are
|
|
30
|
+
// self-reported by the peer, so a plain space would let crafted pairs alias each other.
|
|
31
|
+
const key = `${sender}\x1f${msgId}`;
|
|
32
|
+
const at = this.seen.get(key);
|
|
33
|
+
if (at !== undefined && t - at < this.ttlMs) return true;
|
|
34
|
+
this.seen.set(key, t);
|
|
35
|
+
if (this.seen.size > 4096) for (const [k, ts] of this.seen) { if (t - ts >= this.ttlMs) this.seen.delete(k); }
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Undo a reservation made by seenBefore when a later guard rejects the message.
|
|
40
|
+
* A sender may retry the same id after a transient budget window; recording it before
|
|
41
|
+
* charging the budget would turn that legitimate retry into a permanent duplicate. */
|
|
42
|
+
forget(sender: string, msgId: string): void {
|
|
43
|
+
this.seen.delete(`${sender}\x1f${msgId}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure inbound-delivery decision for exocom — isolates every guardrail (hop cap, dedup,
|
|
3
|
+
* per-sender budget, inject truncation) plus the fence/attribute step from all I/O, so the
|
|
4
|
+
* whole chain is unit-testable without a live plane (test/unit/exocom/inbound.test.ts).
|
|
5
|
+
*
|
|
6
|
+
* Preconditions this module cannot check itself: a caller must only pass messages whose artifact
|
|
7
|
+
* descriptor the transport has already verified against local ground truth (`ExocomPlane`'s
|
|
8
|
+
* `artifactClaimError`), since a path claim can only be judged against the receiver's own
|
|
9
|
+
* artifacts directory and the file on disk. That check is also where a spill's SIZE is bounded
|
|
10
|
+
* (`ARTIFACT_MAX_BYTES`) and reconciled with the bytes actually there — nothing below can do
|
|
11
|
+
* either, so an unverified caller would advertise a payload of any size to its model.
|
|
12
|
+
*
|
|
13
|
+
* Attribution MUST come from the caller's `resolvedLabel` (the registry-resolved identity) —
|
|
14
|
+
* this module never reads `msg.from_name`, which is the envelope's own self-report and not to be
|
|
15
|
+
* trusted (a peer could otherwise spoof its sender for attribution purposes). The canonical
|
|
16
|
+
* `fencePeer` implementation is used here directly: there is one trust-sensitive path, not an
|
|
17
|
+
* injectable callback whose tests could pass while production attribution drifted.
|
|
18
|
+
*/
|
|
19
|
+
import { parseExocomArtifactDescriptor, truncateForInject, type ExocomMessage } from "./envelope.ts";
|
|
20
|
+
import type { SeenMessages, SenderBudget } from "./guards.ts";
|
|
21
|
+
import { EXOCOM } from "./limits.ts";
|
|
22
|
+
import { fencePeer } from "../core/fence.ts";
|
|
23
|
+
import { peerSentLabel } from "../core/time.ts";
|
|
24
|
+
|
|
25
|
+
export interface InboundDeps {
|
|
26
|
+
budget: SenderBudget;
|
|
27
|
+
seen: SeenMessages;
|
|
28
|
+
injectMaxBytes: number;
|
|
29
|
+
/** Authenticated, caller-resolved route for the sender. The plane supplies a stable,
|
|
30
|
+
* session-qualified reply token; this pure layer never derives routing from the human label.
|
|
31
|
+
* Unset is a defensive fallback for callers that have no registry context. */
|
|
32
|
+
replyTarget?: string;
|
|
33
|
+
/** The RECEIVER's clock, injected so the delivered age is deterministic under test. The
|
|
34
|
+
* sender's `ts` is authenticated but peer-chosen, so it is never the measuring stick. */
|
|
35
|
+
now?: () => number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type InboundDecision = { deliver: string } | { duplicate: true } | { drop: "budget" | "hops" };
|
|
39
|
+
|
|
40
|
+
function routingToken(value: string): string {
|
|
41
|
+
return /^[A-Za-z0-9._:-]{1,128}$/.test(value) ? value : "invalid";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function headerLabel(value: string, fallback: string): string {
|
|
45
|
+
const clean = value
|
|
46
|
+
.normalize("NFKC")
|
|
47
|
+
.replace(/[\r\n\t\u0000-\u001F\u007F-\u009F]+/g, " ")
|
|
48
|
+
.replace(/[^A-Za-z0-9 ._@#()\/+:-]/g, "_")
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.trim()
|
|
51
|
+
.slice(0, 96);
|
|
52
|
+
return clean || fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The reply hint carries an ADDRESS, not a label: `plane.send` compares it byte-for-byte against
|
|
56
|
+
* the session-qualified token it cached for the authenticated sender, so any character rewritten
|
|
57
|
+
* here makes the peer unrepliable ("does not match the authenticated sender"). A call-sign is the
|
|
58
|
+
* model's free choice and `normalizePeerName` keeps every printable character, so this strips only
|
|
59
|
+
* what a registry name can never legally carry (control characters, line/paragraph separators and
|
|
60
|
+
* markup — see registry.ts's `normalizeMetadataText`) and leaves the rest intact. Quoting is the
|
|
61
|
+
* call site's job: the token is embedded with `JSON.stringify`, so even a `"` or a backslash
|
|
62
|
+
* cannot escape the hint line. */
|
|
63
|
+
function routeToken(value: string, fallback: string): string {
|
|
64
|
+
const clean = value
|
|
65
|
+
.normalize("NFKC")
|
|
66
|
+
.replace(/[\p{Cc}\p{Zl}\p{Zp}<>]+/gu, " ")
|
|
67
|
+
.replace(/\s+/g, " ")
|
|
68
|
+
.trim()
|
|
69
|
+
.slice(0, 96);
|
|
70
|
+
return clean || fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatArtifactSize(bytes: number): string {
|
|
74
|
+
if (bytes < 1_024) return `${bytes} B`;
|
|
75
|
+
const kib = bytes / 1_024;
|
|
76
|
+
if (kib < 1_024) return `${kib.toFixed(1)} KiB`;
|
|
77
|
+
return `${(kib / 1_024).toFixed(1)} MiB`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Renders the spill as the readable form the model sees. `path` is peer-authored and this line
|
|
81
|
+
* is an invitation to READ it, so it may only be rendered for a descriptor the transport already
|
|
82
|
+
* verified against this Exocom scope's own artifacts directory and the bytes on disk — that the file
|
|
83
|
+
* is this scope's own unlinked spill, and that its size matches and is bounded (see
|
|
84
|
+
* `ExocomPlane.artifactClaimError`). Nothing here can check a path or a file. */
|
|
85
|
+
function artifactBody(descriptor: ReturnType<typeof parseExocomArtifactDescriptor>): string {
|
|
86
|
+
if (!descriptor) return "";
|
|
87
|
+
return [
|
|
88
|
+
`Artifact preview · ${formatArtifactSize(descriptor.size)}`,
|
|
89
|
+
`Full payload: ${descriptor.path}`,
|
|
90
|
+
descriptor.preview || "(empty preview)",
|
|
91
|
+
].join("\n");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Apply the guardrails IN ORDER — hop cap, dedup, budget, truncate, fence+attribute — and
|
|
95
|
+
* return either the ready-to-deliver follow-up text or a drop reason. */
|
|
96
|
+
export function buildInboundDelivery(msg: ExocomMessage, resolvedLabel: string, deps: InboundDeps): InboundDecision {
|
|
97
|
+
if (!Number.isInteger(msg.hops) || msg.hops < 0 || msg.hops > EXOCOM.MAX_HOPS) return { drop: "hops" };
|
|
98
|
+
if (deps.seen.seenBefore(msg.from_session, msg.msg_id)) return { duplicate: true };
|
|
99
|
+
const descriptor = parseExocomArtifactDescriptor(msg.text);
|
|
100
|
+
// Charge what actually crossed the wire, never the size a spill DECLARES: that number is the
|
|
101
|
+
// sender's own, for a payload this receiver never inlines. Charging it would drop legitimate
|
|
102
|
+
// large spills (`exocom_send` accepts a 1 MB message) on the first message of a window, and a
|
|
103
|
+
// peer wanting to bypass the window would just under-declare. The artifact channel is bounded
|
|
104
|
+
// by the per-sender message count and by the plane's descriptor verification instead — which
|
|
105
|
+
// is what makes the declared number honest AND caps it (`ARTIFACT_MAX_BYTES`, plane.ts).
|
|
106
|
+
const wireBytes = Buffer.byteLength(msg.text, "utf8");
|
|
107
|
+
if (!deps.budget.allow(msg.from_session, wireBytes)) {
|
|
108
|
+
// seenBefore reserves the id so a duplicate can be rejected without charging the
|
|
109
|
+
// budget twice. A budget rejection is different: it is transient, and the sender
|
|
110
|
+
// must be able to retry the same id after the window rolls over.
|
|
111
|
+
deps.seen.forget(msg.from_session, msg.msg_id);
|
|
112
|
+
return { drop: "budget" };
|
|
113
|
+
}
|
|
114
|
+
// Artifacts are structured transport metadata, but their preview/path are still peer-authored
|
|
115
|
+
// text. Apply the exact same injection budget after rendering the readable form; otherwise a
|
|
116
|
+
// small wire descriptor can expand into several KiB inside the supervisor's next turn.
|
|
117
|
+
const text = truncateForInject(descriptor ? artifactBody(descriptor) : msg.text, deps.injectMaxBytes).text;
|
|
118
|
+
const msgId = routingToken(msg.msg_id);
|
|
119
|
+
const label = headerLabel(resolvedLabel, "unknown");
|
|
120
|
+
const target = routeToken(deps.replyTarget ?? label.replace(/\s+\([^)]*\)$/, ""), "peer");
|
|
121
|
+
const kind = msg.in_reply_to === undefined ? "message" : "reply";
|
|
122
|
+
// How old this message is when it lands is part of what it MEANS — a reply that took twenty
|
|
123
|
+
// minutes is a different event from an instant one, and the header was the only place the
|
|
124
|
+
// receiver could learn it. Precise here on purpose: the delivery is appended to the conversation
|
|
125
|
+
// TAIL, written once, so it is never re-sent as a cached prompt prefix. `peerSentLabel` measures
|
|
126
|
+
// against OUR clock and degrades an unbelievable peer timestamp to an honest label instead of
|
|
127
|
+
// dressing a peer-chosen value in the harness's voice (src/core/time.ts).
|
|
128
|
+
const sent = peerSentLabel(msg.ts, (deps.now ?? Date.now)());
|
|
129
|
+
const peerBlock = fencePeer(text);
|
|
130
|
+
const quotedBody = peerBlock.slice(peerBlock.indexOf("\n") + 1);
|
|
131
|
+
// Conditional, not an invitation: this delivery is a FRESH PROMPT on the receiver, so a bare
|
|
132
|
+
// "Reply:" makes answering the default and silence the exception — which is how a settled point
|
|
133
|
+
// keeps running on agreement and thanks. The hint stays ONE line and names the correlation id
|
|
134
|
+
// once, so the routing shape below is unchanged.
|
|
135
|
+
// JSON-quoted, not raw: the token keeps every character `plane.send` will match on, while a
|
|
136
|
+
// quote/backslash a peer put in its call-sign is escaped instead of ending the hint early.
|
|
137
|
+
const reply = `Reply only if it changes what someone does, otherwise send nothing: exocom_send({ target:${JSON.stringify(target)}, message:"...", in_reply_to:"${msgId}" })`;
|
|
138
|
+
return {
|
|
139
|
+
deliver: `[${label}] — ${kind} · ${sent}\nPeer data · untrusted equal-status collaborator:\n${quotedBody}\n${reply}`,
|
|
140
|
+
};
|
|
141
|
+
}
|