@trim21/personal-pi-extensions 0.0.208 → 0.0.210
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/package.json +1 -1
- package/src/bwrap/index.ts +87 -25
- package/src/lib/cli-args.ts +116 -0
- package/src/lib/cli.ts +340 -0
- package/src/talk/core.ts +72 -70
- package/src/talk/format.ts +14 -15
- package/src/talk/group.ts +11 -14
- package/src/talk/index.ts +156 -84
- package/src/talk/mailbox.ts +27 -6
- package/src/talk/policy.ts +1 -1
- package/src/talk/registry.ts +50 -27
- package/src/talk/skills/multi-agent-dev/SKILL.md +27 -26
- package/src/talk/storage.ts +1 -1
package/src/talk/core.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* storage backend, and yields deliveries and notifications to an adapter
|
|
4
4
|
* through events. Pi-free — the pi adapter (index.ts) owns the pi API surface.
|
|
5
5
|
*
|
|
6
|
-
* Visibility model: groups are the only visibility boundary.
|
|
7
|
-
* group sees only its co-members;
|
|
6
|
+
* Visibility model: groups are the only visibility boundary. An agent in a
|
|
7
|
+
* group sees only its co-members; an agent in no group sees only itself.
|
|
8
8
|
* Membership is read live from storage on every operation, so joining or
|
|
9
|
-
* leaving a group takes effect immediately for every
|
|
9
|
+
* leaving a group takes effect immediately for every agent.
|
|
10
10
|
*
|
|
11
11
|
* Delivery model: a letter is removed from the inbox only AFTER the adapter
|
|
12
|
-
* reports it was handed to the
|
|
12
|
+
* reports it was handed to the agent (`events.deliver` returns true). A
|
|
13
13
|
* letter whose delivery fails stays in the inbox and is retried on the next
|
|
14
14
|
* poll — so a swallowed sendMessage error no longer destroys the letter.
|
|
15
15
|
*/
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { age, formatListing, refusalUnknown, shortAddr } from "./format.js";
|
|
18
18
|
import {
|
|
19
19
|
deleteGroup,
|
|
20
|
-
|
|
20
|
+
groupForAgent,
|
|
21
21
|
isValidGroupName,
|
|
22
22
|
listGroups,
|
|
23
23
|
newGroupId,
|
|
@@ -45,12 +45,12 @@ import {
|
|
|
45
45
|
} from "./mailbox.js";
|
|
46
46
|
import { inboundAccepts, OutboundPolicy } from "./policy.js";
|
|
47
47
|
import {
|
|
48
|
+
type AgentRecord,
|
|
48
49
|
listRecords,
|
|
49
50
|
type Presence,
|
|
50
51
|
presenceOf,
|
|
51
52
|
readRecord,
|
|
52
53
|
readStartTime,
|
|
53
|
-
type SessionRecord,
|
|
54
54
|
sweep,
|
|
55
55
|
writeRecord,
|
|
56
56
|
} from "./registry.js";
|
|
@@ -58,7 +58,7 @@ import type { TalkStorage } from "./storage.js";
|
|
|
58
58
|
|
|
59
59
|
type AskOutcome =
|
|
60
60
|
{ replied: true; body: string; from: string } | { replied: false; reason: string };
|
|
61
|
-
type TargetResult = { ok: true; record:
|
|
61
|
+
type TargetResult = { ok: true; record: AgentRecord } | { ok: false; error: string };
|
|
62
62
|
type SendResult = { ok: true; letter: Letter; verdict: string } | { ok: false; error: string };
|
|
63
63
|
|
|
64
64
|
export interface TalkCoreEvents {
|
|
@@ -84,14 +84,14 @@ const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
|
|
|
84
84
|
* Mutual-ask arbitration: true when the peer asked first. The `ts` fields of
|
|
85
85
|
* the two ask letters are fixed values inside the letters, so both sides
|
|
86
86
|
* compare the same pair and reach symmetric conclusions. On a same-ms
|
|
87
|
-
* collision, `cwd +
|
|
87
|
+
* collision, `cwd + agentId` breaks the tie deterministically.
|
|
88
88
|
*/
|
|
89
89
|
export function peerAskedFirst(
|
|
90
|
-
peer: { ts: number; cwd: string;
|
|
91
|
-
self: { ts: number; cwd: string;
|
|
90
|
+
peer: { ts: number; cwd: string; agentId: string },
|
|
91
|
+
self: { ts: number; cwd: string; agentId: string },
|
|
92
92
|
): boolean {
|
|
93
|
-
const peerKey = `${peer.cwd}\u0000${peer.
|
|
94
|
-
const selfKey = `${self.cwd}\u0000${self.
|
|
93
|
+
const peerKey = `${peer.cwd}\u0000${peer.agentId}`;
|
|
94
|
+
const selfKey = `${self.cwd}\u0000${self.agentId}`;
|
|
95
95
|
return peer.ts < self.ts || (peer.ts === self.ts && peerKey < selfKey);
|
|
96
96
|
}
|
|
97
97
|
|
|
@@ -100,7 +100,7 @@ export class TalkCore {
|
|
|
100
100
|
private readonly events: TalkCoreEvents;
|
|
101
101
|
private readonly now: () => number;
|
|
102
102
|
|
|
103
|
-
private self:
|
|
103
|
+
private self: AgentRecord | undefined;
|
|
104
104
|
private readonly policy = new OutboundPolicy();
|
|
105
105
|
private readonly askWaiters = new Map<string, (outcome: AskOutcome) => void>();
|
|
106
106
|
private readonly watched = new Map<string, Presence>();
|
|
@@ -124,7 +124,7 @@ export class TalkCore {
|
|
|
124
124
|
return this.self?.addr;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
private requireSelf():
|
|
127
|
+
private requireSelf(): AgentRecord {
|
|
128
128
|
const self = this.self;
|
|
129
129
|
if (!self) throw new Error("Talk core is not started");
|
|
130
130
|
return self;
|
|
@@ -132,7 +132,7 @@ export class TalkCore {
|
|
|
132
132
|
|
|
133
133
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
134
134
|
|
|
135
|
-
async start(self:
|
|
135
|
+
async start(self: AgentRecord): Promise<void> {
|
|
136
136
|
await this.storage.init();
|
|
137
137
|
// Record the process start time so presence can rule out pid reuse later.
|
|
138
138
|
const pidStart = readStartTime(self.pid);
|
|
@@ -141,7 +141,7 @@ export class TalkCore {
|
|
|
141
141
|
try {
|
|
142
142
|
await sweep(this.storage, this.now());
|
|
143
143
|
} catch {
|
|
144
|
-
// sweep failure never breaks the
|
|
144
|
+
// sweep failure never breaks the agent
|
|
145
145
|
}
|
|
146
146
|
this.startInboxPoll();
|
|
147
147
|
// Reclaim dead records periodically, not just at startup.
|
|
@@ -150,7 +150,7 @@ export class TalkCore {
|
|
|
150
150
|
}, SWEEP_INTERVAL_MS);
|
|
151
151
|
this.sweeper.unref();
|
|
152
152
|
// Drain mail queued while offline — deferred: delivering during
|
|
153
|
-
// session_start races the
|
|
153
|
+
// session_start races the agent's own first turn.
|
|
154
154
|
const initial = setTimeout(() => {
|
|
155
155
|
void this.checkInbox();
|
|
156
156
|
}, INITIAL_DRAIN_DELAY_MS);
|
|
@@ -183,18 +183,18 @@ export class TalkCore {
|
|
|
183
183
|
void this.writeSelf({ status: "idle" });
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
setAgentName(name: string): void {
|
|
187
187
|
void this.writeSelf({ name });
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
private async writeSelf(patch: Partial<
|
|
190
|
+
private async writeSelf(patch: Partial<AgentRecord>): Promise<void> {
|
|
191
191
|
if (!this.self) return;
|
|
192
|
-
// A dead
|
|
192
|
+
// A dead agent pins lastSeenAt to 0 so no later event re-freshens it.
|
|
193
193
|
this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
|
|
194
194
|
try {
|
|
195
195
|
await writeRecord(this.storage, this.self);
|
|
196
196
|
} catch {
|
|
197
|
-
// registration failures never break the
|
|
197
|
+
// registration failures never break the agent
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -297,32 +297,32 @@ export class TalkCore {
|
|
|
297
297
|
// ── Outbound ───────────────────────────────────────────────────────────
|
|
298
298
|
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
300
|
+
* Agent ids of the caller's group members, or null when the caller is in
|
|
301
301
|
* no group. Visibility is read live from storage on every operation, so a
|
|
302
|
-
* group change takes effect immediately for every
|
|
302
|
+
* group change takes effect immediately for every agent.
|
|
303
303
|
*/
|
|
304
304
|
private async myGroupMemberIds(): Promise<Set<string> | null> {
|
|
305
305
|
const self = this.requireSelf();
|
|
306
|
-
const group = await
|
|
306
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
307
307
|
return group ? new Set(group.members) : null;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
/**
|
|
311
|
-
* Resolve a target by its exact
|
|
312
|
-
* caller's group are reachable;
|
|
311
|
+
* Resolve a target by its exact agent id (uuid). Only co-members of the
|
|
312
|
+
* caller's group are reachable; an agent in no group sees no peers at all.
|
|
313
313
|
*/
|
|
314
314
|
private async resolveTarget(to: string): Promise<TargetResult> {
|
|
315
315
|
const self = this.requireSelf();
|
|
316
316
|
const records = await listRecords(this.storage);
|
|
317
317
|
const memberIds = await this.myGroupMemberIds();
|
|
318
|
-
const others = records.filter((r) => r.addr !== self.addr && memberIds?.has(r.
|
|
319
|
-
const target = others.find((r) => r.
|
|
318
|
+
const others = records.filter((r) => r.addr !== self.addr && memberIds?.has(r.agentId));
|
|
319
|
+
const target = others.find((r) => r.agentId === to);
|
|
320
320
|
if (!target) return { ok: false, error: refusalUnknown(to) };
|
|
321
321
|
return { ok: true, record: target };
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
private async sendLetter(
|
|
325
|
-
target:
|
|
325
|
+
target: AgentRecord,
|
|
326
326
|
kind: LetterKind,
|
|
327
327
|
body: string,
|
|
328
328
|
replyTo?: string,
|
|
@@ -337,7 +337,7 @@ export class TalkCore {
|
|
|
337
337
|
if (!verdict.ok) return { ok: false, error: verdict.reason };
|
|
338
338
|
const letter: Letter = {
|
|
339
339
|
id: newMessageId(),
|
|
340
|
-
from: { addr: self.addr, name: self.name, cwd: self.cwd,
|
|
340
|
+
from: { addr: self.addr, name: self.name, cwd: self.cwd, agentId: self.agentId },
|
|
341
341
|
kind,
|
|
342
342
|
body,
|
|
343
343
|
ts: this.now(),
|
|
@@ -351,9 +351,7 @@ export class TalkCore {
|
|
|
351
351
|
ok: true,
|
|
352
352
|
letter,
|
|
353
353
|
verdict:
|
|
354
|
-
receipt === "delivered"
|
|
355
|
-
? "delivered"
|
|
356
|
-
: "queued (waits on disk until the session resumes)",
|
|
354
|
+
receipt === "delivered" ? "delivered" : "queued (waits on disk until the agent resumes)",
|
|
357
355
|
};
|
|
358
356
|
}
|
|
359
357
|
return {
|
|
@@ -407,7 +405,7 @@ export class TalkCore {
|
|
|
407
405
|
* - the later ask yields: its waiter is settled with a "peer asked first"
|
|
408
406
|
* reason, and the peer's ask is delivered so this side answers it first.
|
|
409
407
|
*
|
|
410
|
-
* On a same-millisecond ts collision,
|
|
408
|
+
* On a same-millisecond ts collision, agent dir + agent id (carried in
|
|
411
409
|
* `letter.from`) breaks the tie deterministically — both sides compute the
|
|
412
410
|
* same comparison and reach symmetric conclusions.
|
|
413
411
|
*/
|
|
@@ -418,8 +416,8 @@ export class TalkCore {
|
|
|
418
416
|
const waiter = this.askWaiters.get(myAsk.askId);
|
|
419
417
|
if (!waiter) return;
|
|
420
418
|
const peerFirst = peerAskedFirst(
|
|
421
|
-
{ ts: letter.ts, cwd: letter.from.cwd,
|
|
422
|
-
{ ts: myAsk.ts, cwd: self.cwd,
|
|
419
|
+
{ ts: letter.ts, cwd: letter.from.cwd, agentId: letter.from.agentId },
|
|
420
|
+
{ ts: myAsk.ts, cwd: self.cwd, agentId: self.agentId },
|
|
423
421
|
);
|
|
424
422
|
if (!peerFirst) return; // we asked first; keep waiting — the peer will yield
|
|
425
423
|
this.askWaiters.delete(myAsk.askId);
|
|
@@ -433,10 +431,10 @@ export class TalkCore {
|
|
|
433
431
|
// ── Tool actions ───────────────────────────────────────────────────────
|
|
434
432
|
|
|
435
433
|
/**
|
|
436
|
-
* JSON listing of visible
|
|
437
|
-
* Grouped
|
|
434
|
+
* JSON listing of visible agents, including self (marked `self: true`).
|
|
435
|
+
* Grouped agents see only their co-members; an agent in no group sees
|
|
438
436
|
* only itself. Every visible record is listed, live or offline; presence
|
|
439
|
-
* decides the per-
|
|
437
|
+
* decides the per-agent status.
|
|
440
438
|
*/
|
|
441
439
|
async list(): Promise<string> {
|
|
442
440
|
const self = this.requireSelf();
|
|
@@ -444,7 +442,7 @@ export class TalkCore {
|
|
|
444
442
|
const memberIds = await this.myGroupMemberIds();
|
|
445
443
|
const records = all.filter((r) => {
|
|
446
444
|
if (r.addr === self.addr) return !this.dead;
|
|
447
|
-
return memberIds?.has(r.
|
|
445
|
+
return memberIds?.has(r.agentId) ?? false;
|
|
448
446
|
});
|
|
449
447
|
return formatListing(records, self.addr, presenceOf);
|
|
450
448
|
}
|
|
@@ -457,30 +455,30 @@ export class TalkCore {
|
|
|
457
455
|
const filtered = records.filter((r) => {
|
|
458
456
|
if (r.cwd !== cwd) return false;
|
|
459
457
|
if (r.addr === self.addr) return !this.dead;
|
|
460
|
-
return memberIds?.has(r.
|
|
458
|
+
return memberIds?.has(r.agentId) ?? false;
|
|
461
459
|
});
|
|
462
460
|
return formatListing(filtered, self.addr, presenceOf);
|
|
463
461
|
}
|
|
464
462
|
|
|
465
463
|
/** Visible peer records (excluding self), e.g. for command completions. */
|
|
466
|
-
async listPeers(): Promise<
|
|
464
|
+
async listPeers(): Promise<AgentRecord[]> {
|
|
467
465
|
const self = this.requireSelf();
|
|
468
466
|
const records = await listRecords(this.storage);
|
|
469
467
|
const memberIds = await this.myGroupMemberIds();
|
|
470
|
-
return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.
|
|
468
|
+
return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.agentId) ?? false));
|
|
471
469
|
}
|
|
472
470
|
|
|
473
471
|
/**
|
|
474
|
-
* Mark
|
|
472
|
+
* Mark an agent as dead: set its offline flag and pin lastSeenAt to 0 so
|
|
475
473
|
* the next sweep reaps it (empty mailbox). Without a target, marks this
|
|
476
|
-
*
|
|
474
|
+
* agent — later writeSelf calls no longer refresh lastSeenAt, and the
|
|
477
475
|
* record stays offline for peers.
|
|
478
476
|
*/
|
|
479
477
|
async markDead(target?: string): Promise<string> {
|
|
480
478
|
if (!target) {
|
|
481
479
|
this.dead = true;
|
|
482
480
|
await this.writeSelf({ offline: true });
|
|
483
|
-
return "Marked this
|
|
481
|
+
return "Marked this agent as dead.";
|
|
484
482
|
}
|
|
485
483
|
const resolved = await this.resolveTarget(target);
|
|
486
484
|
if (!resolved.ok) return resolved.error;
|
|
@@ -494,7 +492,7 @@ export class TalkCore {
|
|
|
494
492
|
for (const peer of peers) {
|
|
495
493
|
await writeRecord(this.storage, { ...peer, lastSeenAt: 0, offline: true });
|
|
496
494
|
}
|
|
497
|
-
return `Marked ${peers.length}
|
|
495
|
+
return `Marked ${peers.length} agent(s) as dead.`;
|
|
498
496
|
}
|
|
499
497
|
|
|
500
498
|
// ── Groups ─────────────────────────────────────────────────────────────
|
|
@@ -502,9 +500,9 @@ export class TalkCore {
|
|
|
502
500
|
/** Remove the caller from its current group, deleting the group when it empties. */
|
|
503
501
|
private async leaveCurrentGroup(): Promise<boolean> {
|
|
504
502
|
const self = this.requireSelf();
|
|
505
|
-
const group = await
|
|
503
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
506
504
|
if (!group) return false;
|
|
507
|
-
const others = group.members.filter((m) => m !== self.
|
|
505
|
+
const others = group.members.filter((m) => m !== self.agentId);
|
|
508
506
|
if (others.length === 0) {
|
|
509
507
|
await deleteGroup(this.storage, group.id);
|
|
510
508
|
} else {
|
|
@@ -517,36 +515,42 @@ export class TalkCore {
|
|
|
517
515
|
* Join or create a group and join it. With no name a fresh uuid is
|
|
518
516
|
* generated; with a name, the group is joined when it exists and created
|
|
519
517
|
* otherwise. Leaving any current group first keeps the single-group
|
|
520
|
-
* invariant.
|
|
518
|
+
* invariant. When `agentName` is given, the agent's display name (what
|
|
519
|
+
* peers see in talk-list-agents) is set to it — also when the agent is
|
|
520
|
+
* already in the group, so re-joining can rename.
|
|
521
521
|
*/
|
|
522
|
-
async groupJoin(groupName?: string): Promise<string> {
|
|
522
|
+
async groupJoin(groupName?: string, agentName?: string): Promise<string> {
|
|
523
523
|
const self = this.requireSelf();
|
|
524
524
|
const name =
|
|
525
525
|
groupName === undefined || groupName.trim() === "" ? newGroupId() : groupName.trim();
|
|
526
526
|
if (!isValidGroupName(name)) {
|
|
527
527
|
return `Invalid group name '${name}'. Allowed: letters, digits, '-' and '_' (max 64 chars).`;
|
|
528
528
|
}
|
|
529
|
+
if (agentName !== undefined) {
|
|
530
|
+
await this.writeSelf({ name: agentName });
|
|
531
|
+
}
|
|
532
|
+
const nameNote = agentName === undefined ? "" : ` You are visible as "${agentName}".`;
|
|
529
533
|
const existing = await readGroup(this.storage, name);
|
|
530
|
-
if (existing?.members.includes(self.
|
|
531
|
-
return `Already in group ${name} (${existing.members.length} member(s))
|
|
534
|
+
if (existing?.members.includes(self.agentId)) {
|
|
535
|
+
return `Already in group ${name} (${existing.members.length} member(s)).${nameNote}`;
|
|
532
536
|
}
|
|
533
537
|
await this.leaveCurrentGroup();
|
|
534
538
|
if (existing) {
|
|
535
539
|
await writeGroup(this.storage, {
|
|
536
540
|
...existing,
|
|
537
|
-
members: [...existing.members, self.
|
|
541
|
+
members: [...existing.members, self.agentId],
|
|
538
542
|
updatedAt: this.now(),
|
|
539
543
|
});
|
|
540
|
-
return `Joined group ${name} (${existing.members.length + 1} member(s)). You now see only co-members
|
|
544
|
+
return `Joined group ${name} (${existing.members.length + 1} member(s)). You now see only co-members.${nameNote}`;
|
|
541
545
|
}
|
|
542
546
|
const now = this.now();
|
|
543
547
|
await writeGroup(this.storage, {
|
|
544
548
|
id: name,
|
|
545
|
-
members: [self.
|
|
549
|
+
members: [self.agentId],
|
|
546
550
|
createdAt: now,
|
|
547
551
|
updatedAt: now,
|
|
548
552
|
});
|
|
549
|
-
return `Created group ${name}
|
|
553
|
+
return `Created group ${name}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
|
|
550
554
|
}
|
|
551
555
|
|
|
552
556
|
/** Join the most recently created group; no-op when already in it. */
|
|
@@ -560,9 +564,9 @@ export class TalkCore {
|
|
|
560
564
|
/** Leave the current group; an emptied group is deleted. */
|
|
561
565
|
async groupLeave(): Promise<string> {
|
|
562
566
|
const self = this.requireSelf();
|
|
563
|
-
const group = await
|
|
567
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
564
568
|
if (!group) return "Not in any group.";
|
|
565
|
-
const others = group.members.filter((m) => m !== self.
|
|
569
|
+
const others = group.members.filter((m) => m !== self.agentId);
|
|
566
570
|
if (others.length === 0) {
|
|
567
571
|
await deleteGroup(this.storage, group.id);
|
|
568
572
|
return `Left group ${group.id} (deleted — it was empty).`;
|
|
@@ -581,7 +585,7 @@ export class TalkCore {
|
|
|
581
585
|
return `Deleted group ${name} (${group.members.length} member(s)).`;
|
|
582
586
|
}
|
|
583
587
|
|
|
584
|
-
/** Delete every group; all
|
|
588
|
+
/** Delete every group; all agents become ungrouped. */
|
|
585
589
|
async groupClear(): Promise<string> {
|
|
586
590
|
const groups = await listGroups(this.storage);
|
|
587
591
|
for (const group of groups) await deleteGroup(this.storage, group.id);
|
|
@@ -597,17 +601,15 @@ export class TalkCore {
|
|
|
597
601
|
const groups = await listGroups(this.storage);
|
|
598
602
|
if (groups.length === 0) return "No groups.";
|
|
599
603
|
const records = await listRecords(this.storage);
|
|
600
|
-
const label = (
|
|
601
|
-
const rec = records.find((r) => r.
|
|
602
|
-
const id =
|
|
603
|
-
return rec ? `${rec.name} (${id})` : `unknown
|
|
604
|
+
const label = (agentId: string): string => {
|
|
605
|
+
const rec = records.find((r) => r.agentId === agentId);
|
|
606
|
+
const id = agentId.length > 8 ? `${agentId.slice(0, 8)}…` : agentId;
|
|
607
|
+
return rec ? `${rec.name} (${id})` : `unknown agent (${id})`;
|
|
604
608
|
};
|
|
605
609
|
const lines = groups
|
|
606
610
|
.toSorted((a, b) => b.createdAt - a.createdAt)
|
|
607
611
|
.map((g) => {
|
|
608
|
-
const members = g.members.map((m) =>
|
|
609
|
-
m === self.sessionId ? `${label(m)} ← you` : label(m),
|
|
610
|
-
);
|
|
612
|
+
const members = g.members.map((m) => (m === self.agentId ? `${label(m)} ← you` : label(m)));
|
|
611
613
|
return `- ${g.id} (created ${age(g.createdAt)}): ${members.join(", ")}`;
|
|
612
614
|
});
|
|
613
615
|
return `Groups (${groups.length}):\n${lines.join("\n")}`;
|
|
@@ -617,7 +619,7 @@ export class TalkCore {
|
|
|
617
619
|
if (!to) return 'send requires "to".';
|
|
618
620
|
if (!body) return 'send requires "message".';
|
|
619
621
|
if (to === "*" || to === "cwd") {
|
|
620
|
-
return "send requires a single
|
|
622
|
+
return "send requires a single agent id; broadcasting is disabled.";
|
|
621
623
|
}
|
|
622
624
|
const resolved = await this.resolveTarget(to);
|
|
623
625
|
if (!resolved.ok) return resolved.error;
|
|
@@ -683,10 +685,10 @@ export class TalkCore {
|
|
|
683
685
|
}
|
|
684
686
|
|
|
685
687
|
/** Build a minimal record from a letter's sender when the peer record is gone. */
|
|
686
|
-
private recordFromLetter(letter: Letter):
|
|
688
|
+
private recordFromLetter(letter: Letter): AgentRecord {
|
|
687
689
|
return {
|
|
688
690
|
addr: letter.from.addr,
|
|
689
|
-
|
|
691
|
+
agentId: letter.from.agentId,
|
|
690
692
|
name: letter.from.name,
|
|
691
693
|
cwd: letter.from.cwd,
|
|
692
694
|
pid: 0,
|
package/src/talk/format.ts
CHANGED
|
@@ -4,10 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { Letter } from "./mailbox.js";
|
|
7
|
-
import type {
|
|
7
|
+
import type { AgentRecord, Presence } from "./registry.js";
|
|
8
8
|
|
|
9
|
-
export const BOUNDARY_PREAMBLE =
|
|
10
|
-
"This came from another pi session (a peer agent), not from the user.";
|
|
9
|
+
export const BOUNDARY_PREAMBLE = "This came from another pi agent, not from the user.";
|
|
11
10
|
|
|
12
11
|
export function shortAddr(addr: string): string {
|
|
13
12
|
return addr.slice(0, 6);
|
|
@@ -21,20 +20,20 @@ export function age(ts: number, now: number = Date.now()): string {
|
|
|
21
20
|
return `${Math.round(m / 60)}h ago`;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
/** Delivery text injected into the receiving
|
|
23
|
+
/** Delivery text injected into the receiving agent's LLM context. */
|
|
25
24
|
export function formatDelivery(letter: Letter, now: number = Date.now()): string {
|
|
26
25
|
const from = letter.from;
|
|
27
|
-
const header = `From pi
|
|
26
|
+
const header = `From pi agent ${from.agentId} (${from.cwd}) — "${from.name}"`;
|
|
28
27
|
const meta = `_id ${letter.id} · ${letter.kind} · sent ${age(letter.ts, now)}_`;
|
|
29
28
|
const hint =
|
|
30
29
|
letter.kind === "ask" ? `\n\nReply with the talk-reply tool, replyTo: "${letter.id}"` : "";
|
|
31
30
|
return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}${hint}`;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
/** One
|
|
35
|
-
*
|
|
36
|
-
* the calling
|
|
37
|
-
export interface
|
|
33
|
+
/** One agent as the model sees it in a listing. `id` is the stable pi
|
|
34
|
+
* agent uuid; `name` is the display name when one was set; `self` marks
|
|
35
|
+
* the calling agent itself. */
|
|
36
|
+
export interface AgentListItem {
|
|
38
37
|
status: string;
|
|
39
38
|
work_dir: string;
|
|
40
39
|
id: string;
|
|
@@ -42,17 +41,17 @@ export interface SessionListItem {
|
|
|
42
41
|
self?: boolean;
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
/** Machine-readable JSON listing of
|
|
44
|
+
/** Machine-readable JSON listing of agents (what the model sees). */
|
|
46
45
|
export function formatListing(
|
|
47
|
-
records:
|
|
46
|
+
records: AgentRecord[],
|
|
48
47
|
selfAddr: string,
|
|
49
|
-
presence: (r:
|
|
48
|
+
presence: (r: AgentRecord) => Presence,
|
|
50
49
|
): string {
|
|
51
50
|
if (records.length === 0) return "[]";
|
|
52
|
-
const items:
|
|
51
|
+
const items: AgentListItem[] = records.map((r) => {
|
|
53
52
|
const p = presence(r);
|
|
54
53
|
const status = p === "live" ? r.status : "offline";
|
|
55
|
-
const item:
|
|
54
|
+
const item: AgentListItem = { status, work_dir: r.cwd, id: r.agentId, name: r.name };
|
|
56
55
|
if (r.addr === selfAddr) item.self = true;
|
|
57
56
|
return item;
|
|
58
57
|
});
|
|
@@ -60,5 +59,5 @@ export function formatListing(
|
|
|
60
59
|
}
|
|
61
60
|
|
|
62
61
|
export function refusalUnknown(to: string): string {
|
|
63
|
-
return `Unknown
|
|
62
|
+
return `Unknown agent id '${to}'. Get agent ids with talk-list-agents.`;
|
|
64
63
|
}
|
package/src/talk/group.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Agent groups for the talk mailbox: an explicit, uuid-addressed set of
|
|
3
|
+
* agents that see only each other. Core layer — depends only on
|
|
4
4
|
* TalkStorage, never on pi.
|
|
5
5
|
*
|
|
6
6
|
* Rules:
|
|
7
|
-
* -
|
|
8
|
-
* - Groups are public: any
|
|
7
|
+
* - An agent belongs to at most one group (single-group invariant).
|
|
8
|
+
* - Groups are public: any agent can join any group by its uuid, and a
|
|
9
9
|
* member can leave freely. There is no owner.
|
|
10
|
-
* - Visibility is fully group-driven: a grouped
|
|
11
|
-
* co-members;
|
|
10
|
+
* - Visibility is fully group-driven: a grouped agent sees only its
|
|
11
|
+
* co-members; an agent in no group sees only itself.
|
|
12
12
|
* - A group that empties is deleted; a one-member group is a normal state
|
|
13
13
|
* (a creator waiting for peers to join).
|
|
14
14
|
*/
|
|
@@ -22,7 +22,7 @@ import type { TalkStorage } from "./storage.js";
|
|
|
22
22
|
|
|
23
23
|
export const GroupSchema = Type.Object({
|
|
24
24
|
id: Type.String(),
|
|
25
|
-
/** pi
|
|
25
|
+
/** pi agent uuids of the members. */
|
|
26
26
|
members: Type.Array(Type.String()),
|
|
27
27
|
createdAt: Type.Number(),
|
|
28
28
|
updatedAt: Type.Number(),
|
|
@@ -79,16 +79,13 @@ export async function deleteGroup(storage: TalkStorage, id: string): Promise<boo
|
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* The group
|
|
83
|
-
* this is at most one; corrupted data that lists the
|
|
82
|
+
* The group an agent currently belongs to. Under the single-group invariant
|
|
83
|
+
* this is at most one; corrupted data that lists the agent in several
|
|
84
84
|
* groups resolves to the first match.
|
|
85
85
|
*/
|
|
86
|
-
export async function
|
|
87
|
-
storage: TalkStorage,
|
|
88
|
-
sessionId: string,
|
|
89
|
-
): Promise<Group | null> {
|
|
86
|
+
export async function groupForAgent(storage: TalkStorage, agentId: string): Promise<Group | null> {
|
|
90
87
|
for (const group of await listGroups(storage)) {
|
|
91
|
-
if (group.members.includes(
|
|
88
|
+
if (group.members.includes(agentId)) return group;
|
|
92
89
|
}
|
|
93
90
|
return null;
|
|
94
91
|
}
|