@trim21/personal-pi-extensions 0.0.206 → 0.0.209
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 +27 -21
- package/package.json +1 -1
- package/src/gh-readonly.ts +213 -10
- package/src/lib/cli-args.ts +116 -0
- package/src/talk/core.ts +195 -84
- package/src/talk/format.ts +14 -15
- package/src/talk/group.ts +91 -0
- package/src/talk/index.ts +95 -50
- 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 +28 -26
- package/src/talk/storage.ts +7 -1
package/src/talk/core.ts
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Talk core: coordinates the registry, mailbox, and policy over a
|
|
3
|
-
* backend, and yields deliveries and notifications to an adapter
|
|
4
|
-
* events. Pi-free — the pi adapter (index.ts) owns the pi API surface.
|
|
2
|
+
* Talk core: coordinates the registry, mailbox, groups, and policy over a
|
|
3
|
+
* storage backend, and yields deliveries and notifications to an adapter
|
|
4
|
+
* through events. Pi-free — the pi adapter (index.ts) owns the pi API surface.
|
|
5
|
+
*
|
|
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
|
+
* Membership is read live from storage on every operation, so joining or
|
|
9
|
+
* leaving a group takes effect immediately for every agent.
|
|
5
10
|
*
|
|
6
11
|
* Delivery model: a letter is removed from the inbox only AFTER the adapter
|
|
7
|
-
* reports it was handed to the
|
|
12
|
+
* reports it was handed to the agent (`events.deliver` returns true). A
|
|
8
13
|
* letter whose delivery fails stays in the inbox and is retried on the next
|
|
9
14
|
* poll — so a swallowed sendMessage error no longer destroys the letter.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
import { age, formatListing, refusalUnknown, shortAddr } from "./format.js";
|
|
18
|
+
import {
|
|
19
|
+
deleteGroup,
|
|
20
|
+
groupForAgent,
|
|
21
|
+
isValidGroupName,
|
|
22
|
+
listGroups,
|
|
23
|
+
newGroupId,
|
|
24
|
+
readGroup,
|
|
25
|
+
writeGroup,
|
|
26
|
+
} from "./group.js";
|
|
16
27
|
import {
|
|
17
28
|
appendAudit,
|
|
18
29
|
awaitReceipt,
|
|
@@ -34,12 +45,12 @@ import {
|
|
|
34
45
|
} from "./mailbox.js";
|
|
35
46
|
import { inboundAccepts, OutboundPolicy } from "./policy.js";
|
|
36
47
|
import {
|
|
48
|
+
type AgentRecord,
|
|
37
49
|
listRecords,
|
|
38
50
|
type Presence,
|
|
39
51
|
presenceOf,
|
|
40
52
|
readRecord,
|
|
41
53
|
readStartTime,
|
|
42
|
-
type SessionRecord,
|
|
43
54
|
sweep,
|
|
44
55
|
writeRecord,
|
|
45
56
|
} from "./registry.js";
|
|
@@ -47,7 +58,7 @@ import type { TalkStorage } from "./storage.js";
|
|
|
47
58
|
|
|
48
59
|
type AskOutcome =
|
|
49
60
|
{ replied: true; body: string; from: string } | { replied: false; reason: string };
|
|
50
|
-
type TargetResult = { ok: true; record:
|
|
61
|
+
type TargetResult = { ok: true; record: AgentRecord } | { ok: false; error: string };
|
|
51
62
|
type SendResult = { ok: true; letter: Letter; verdict: string } | { ok: false; error: string };
|
|
52
63
|
|
|
53
64
|
export interface TalkCoreEvents {
|
|
@@ -69,44 +80,18 @@ const DELIVERY_BACKOFF_MS = 5000;
|
|
|
69
80
|
const INITIAL_DRAIN_DELAY_MS = 1200;
|
|
70
81
|
const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
|
|
71
82
|
|
|
72
|
-
/** Normalize an allowed path: expand ~, resolve relative against baseCwd, strip trailing slashes. */
|
|
73
|
-
function normalizeAllowedPath(p: string, baseCwd: string): string {
|
|
74
|
-
const expanded = expandHome(p);
|
|
75
|
-
const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(baseCwd, expanded);
|
|
76
|
-
return abs.replace(/[\\/]+$/, "") || "/";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Build the workspace-visibility gate for one session. `allowed` is the
|
|
81
|
-
* `allowed` array from `<cwd>/.pi/talk.json` (undefined when the file or key
|
|
82
|
-
* is absent). A peer session is visible when its cwd equals an allowed prefix
|
|
83
|
-
* or sits below it (`prefix` or `prefix/*`); `company1` never matches
|
|
84
|
-
* `company12`. An undefined list shows everything; an explicit empty list
|
|
85
|
-
* shows nothing.
|
|
86
|
-
*/
|
|
87
|
-
export function buildVisibilityFilter(
|
|
88
|
-
allowed: string[] | undefined,
|
|
89
|
-
baseCwd: string,
|
|
90
|
-
): (peerCwd: string) => boolean {
|
|
91
|
-
if (allowed === undefined) return () => true;
|
|
92
|
-
if (allowed.length === 0) return () => false;
|
|
93
|
-
const prefixes = allowed.map((p) => normalizeAllowedPath(p, baseCwd));
|
|
94
|
-
return (peerCwd) =>
|
|
95
|
-
prefixes.some((prefix) => peerCwd === prefix || peerCwd.startsWith(`${prefix}/`));
|
|
96
|
-
}
|
|
97
|
-
|
|
98
83
|
/**
|
|
99
84
|
* Mutual-ask arbitration: true when the peer asked first. The `ts` fields of
|
|
100
85
|
* the two ask letters are fixed values inside the letters, so both sides
|
|
101
86
|
* compare the same pair and reach symmetric conclusions. On a same-ms
|
|
102
|
-
* collision, `cwd +
|
|
87
|
+
* collision, `cwd + agentId` breaks the tie deterministically.
|
|
103
88
|
*/
|
|
104
89
|
export function peerAskedFirst(
|
|
105
|
-
peer: { ts: number; cwd: string;
|
|
106
|
-
self: { ts: number; cwd: string;
|
|
90
|
+
peer: { ts: number; cwd: string; agentId: string },
|
|
91
|
+
self: { ts: number; cwd: string; agentId: string },
|
|
107
92
|
): boolean {
|
|
108
|
-
const peerKey = `${peer.cwd}\u0000${peer.
|
|
109
|
-
const selfKey = `${self.cwd}\u0000${self.
|
|
93
|
+
const peerKey = `${peer.cwd}\u0000${peer.agentId}`;
|
|
94
|
+
const selfKey = `${self.cwd}\u0000${self.agentId}`;
|
|
110
95
|
return peer.ts < self.ts || (peer.ts === self.ts && peerKey < selfKey);
|
|
111
96
|
}
|
|
112
97
|
|
|
@@ -115,14 +100,12 @@ export class TalkCore {
|
|
|
115
100
|
private readonly events: TalkCoreEvents;
|
|
116
101
|
private readonly now: () => number;
|
|
117
102
|
|
|
118
|
-
private self:
|
|
103
|
+
private self: AgentRecord | undefined;
|
|
119
104
|
private readonly policy = new OutboundPolicy();
|
|
120
105
|
private readonly askWaiters = new Map<string, (outcome: AskOutcome) => void>();
|
|
121
106
|
private readonly watched = new Map<string, Presence>();
|
|
122
107
|
/** Message ids already handed to the adapter but not yet removed from the inbox. */
|
|
123
108
|
private readonly deliveredIds = new Set<string>();
|
|
124
|
-
/** Visibility gate over peer working directories; defaults to everything visible. */
|
|
125
|
-
private isPeerVisible: (peerCwd: string) => boolean = () => true;
|
|
126
109
|
/** Manually marked dead: offline flag set, lastSeenAt pinned to 0. */
|
|
127
110
|
private dead = false;
|
|
128
111
|
|
|
@@ -141,12 +124,7 @@ export class TalkCore {
|
|
|
141
124
|
return this.self?.addr;
|
|
142
125
|
}
|
|
143
126
|
|
|
144
|
-
|
|
145
|
-
setPeerVisibility(filter: (peerCwd: string) => boolean): void {
|
|
146
|
-
this.isPeerVisible = filter;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
private requireSelf(): SessionRecord {
|
|
127
|
+
private requireSelf(): AgentRecord {
|
|
150
128
|
const self = this.self;
|
|
151
129
|
if (!self) throw new Error("Talk core is not started");
|
|
152
130
|
return self;
|
|
@@ -154,7 +132,7 @@ export class TalkCore {
|
|
|
154
132
|
|
|
155
133
|
// ── Lifecycle ──────────────────────────────────────────────────────────
|
|
156
134
|
|
|
157
|
-
async start(self:
|
|
135
|
+
async start(self: AgentRecord): Promise<void> {
|
|
158
136
|
await this.storage.init();
|
|
159
137
|
// Record the process start time so presence can rule out pid reuse later.
|
|
160
138
|
const pidStart = readStartTime(self.pid);
|
|
@@ -163,7 +141,7 @@ export class TalkCore {
|
|
|
163
141
|
try {
|
|
164
142
|
await sweep(this.storage, this.now());
|
|
165
143
|
} catch {
|
|
166
|
-
// sweep failure never breaks the
|
|
144
|
+
// sweep failure never breaks the agent
|
|
167
145
|
}
|
|
168
146
|
this.startInboxPoll();
|
|
169
147
|
// Reclaim dead records periodically, not just at startup.
|
|
@@ -172,7 +150,7 @@ export class TalkCore {
|
|
|
172
150
|
}, SWEEP_INTERVAL_MS);
|
|
173
151
|
this.sweeper.unref();
|
|
174
152
|
// Drain mail queued while offline — deferred: delivering during
|
|
175
|
-
// session_start races the
|
|
153
|
+
// session_start races the agent's own first turn.
|
|
176
154
|
const initial = setTimeout(() => {
|
|
177
155
|
void this.checkInbox();
|
|
178
156
|
}, INITIAL_DRAIN_DELAY_MS);
|
|
@@ -205,18 +183,18 @@ export class TalkCore {
|
|
|
205
183
|
void this.writeSelf({ status: "idle" });
|
|
206
184
|
}
|
|
207
185
|
|
|
208
|
-
|
|
186
|
+
setAgentName(name: string): void {
|
|
209
187
|
void this.writeSelf({ name });
|
|
210
188
|
}
|
|
211
189
|
|
|
212
|
-
private async writeSelf(patch: Partial<
|
|
190
|
+
private async writeSelf(patch: Partial<AgentRecord>): Promise<void> {
|
|
213
191
|
if (!this.self) return;
|
|
214
|
-
// A dead
|
|
192
|
+
// A dead agent pins lastSeenAt to 0 so no later event re-freshens it.
|
|
215
193
|
this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
|
|
216
194
|
try {
|
|
217
195
|
await writeRecord(this.storage, this.self);
|
|
218
196
|
} catch {
|
|
219
|
-
// registration failures never break the
|
|
197
|
+
// registration failures never break the agent
|
|
220
198
|
}
|
|
221
199
|
}
|
|
222
200
|
|
|
@@ -319,21 +297,32 @@ export class TalkCore {
|
|
|
319
297
|
// ── Outbound ───────────────────────────────────────────────────────────
|
|
320
298
|
|
|
321
299
|
/**
|
|
322
|
-
*
|
|
323
|
-
* from
|
|
324
|
-
*
|
|
300
|
+
* Agent ids of the caller's group members, or null when the caller is in
|
|
301
|
+
* no group. Visibility is read live from storage on every operation, so a
|
|
302
|
+
* group change takes effect immediately for every agent.
|
|
303
|
+
*/
|
|
304
|
+
private async myGroupMemberIds(): Promise<Set<string> | null> {
|
|
305
|
+
const self = this.requireSelf();
|
|
306
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
307
|
+
return group ? new Set(group.members) : null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
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.
|
|
325
313
|
*/
|
|
326
314
|
private async resolveTarget(to: string): Promise<TargetResult> {
|
|
327
315
|
const self = this.requireSelf();
|
|
328
316
|
const records = await listRecords(this.storage);
|
|
329
|
-
const
|
|
330
|
-
const
|
|
317
|
+
const memberIds = await this.myGroupMemberIds();
|
|
318
|
+
const others = records.filter((r) => r.addr !== self.addr && memberIds?.has(r.agentId));
|
|
319
|
+
const target = others.find((r) => r.agentId === to);
|
|
331
320
|
if (!target) return { ok: false, error: refusalUnknown(to) };
|
|
332
321
|
return { ok: true, record: target };
|
|
333
322
|
}
|
|
334
323
|
|
|
335
324
|
private async sendLetter(
|
|
336
|
-
target:
|
|
325
|
+
target: AgentRecord,
|
|
337
326
|
kind: LetterKind,
|
|
338
327
|
body: string,
|
|
339
328
|
replyTo?: string,
|
|
@@ -348,7 +337,7 @@ export class TalkCore {
|
|
|
348
337
|
if (!verdict.ok) return { ok: false, error: verdict.reason };
|
|
349
338
|
const letter: Letter = {
|
|
350
339
|
id: newMessageId(),
|
|
351
|
-
from: { addr: self.addr, name: self.name, cwd: self.cwd,
|
|
340
|
+
from: { addr: self.addr, name: self.name, cwd: self.cwd, agentId: self.agentId },
|
|
352
341
|
kind,
|
|
353
342
|
body,
|
|
354
343
|
ts: this.now(),
|
|
@@ -362,9 +351,7 @@ export class TalkCore {
|
|
|
362
351
|
ok: true,
|
|
363
352
|
letter,
|
|
364
353
|
verdict:
|
|
365
|
-
receipt === "delivered"
|
|
366
|
-
? "delivered"
|
|
367
|
-
: "queued (waits on disk until the session resumes)",
|
|
354
|
+
receipt === "delivered" ? "delivered" : "queued (waits on disk until the agent resumes)",
|
|
368
355
|
};
|
|
369
356
|
}
|
|
370
357
|
return {
|
|
@@ -418,7 +405,7 @@ export class TalkCore {
|
|
|
418
405
|
* - the later ask yields: its waiter is settled with a "peer asked first"
|
|
419
406
|
* reason, and the peer's ask is delivered so this side answers it first.
|
|
420
407
|
*
|
|
421
|
-
* On a same-millisecond ts collision,
|
|
408
|
+
* On a same-millisecond ts collision, agent dir + agent id (carried in
|
|
422
409
|
* `letter.from`) breaks the tie deterministically — both sides compute the
|
|
423
410
|
* same comparison and reach symmetric conclusions.
|
|
424
411
|
*/
|
|
@@ -429,8 +416,8 @@ export class TalkCore {
|
|
|
429
416
|
const waiter = this.askWaiters.get(myAsk.askId);
|
|
430
417
|
if (!waiter) return;
|
|
431
418
|
const peerFirst = peerAskedFirst(
|
|
432
|
-
{ ts: letter.ts, cwd: letter.from.cwd,
|
|
433
|
-
{ 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 },
|
|
434
421
|
);
|
|
435
422
|
if (!peerFirst) return; // we asked first; keep waiting — the peer will yield
|
|
436
423
|
this.askWaiters.delete(myAsk.askId);
|
|
@@ -444,16 +431,18 @@ export class TalkCore {
|
|
|
444
431
|
// ── Tool actions ───────────────────────────────────────────────────────
|
|
445
432
|
|
|
446
433
|
/**
|
|
447
|
-
* JSON listing of visible
|
|
448
|
-
*
|
|
449
|
-
*
|
|
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
|
|
436
|
+
* only itself. Every visible record is listed, live or offline; presence
|
|
437
|
+
* decides the per-agent status.
|
|
450
438
|
*/
|
|
451
439
|
async list(): Promise<string> {
|
|
452
440
|
const self = this.requireSelf();
|
|
453
441
|
const all = await listRecords(this.storage);
|
|
442
|
+
const memberIds = await this.myGroupMemberIds();
|
|
454
443
|
const records = all.filter((r) => {
|
|
455
444
|
if (r.addr === self.addr) return !this.dead;
|
|
456
|
-
return
|
|
445
|
+
return memberIds?.has(r.agentId) ?? false;
|
|
457
446
|
});
|
|
458
447
|
return formatListing(records, self.addr, presenceOf);
|
|
459
448
|
}
|
|
@@ -462,32 +451,34 @@ export class TalkCore {
|
|
|
462
451
|
async listCwd(cwd: string): Promise<string> {
|
|
463
452
|
const self = this.requireSelf();
|
|
464
453
|
const records = await listRecords(this.storage);
|
|
454
|
+
const memberIds = await this.myGroupMemberIds();
|
|
465
455
|
const filtered = records.filter((r) => {
|
|
466
456
|
if (r.cwd !== cwd) return false;
|
|
467
457
|
if (r.addr === self.addr) return !this.dead;
|
|
468
|
-
return
|
|
458
|
+
return memberIds?.has(r.agentId) ?? false;
|
|
469
459
|
});
|
|
470
460
|
return formatListing(filtered, self.addr, presenceOf);
|
|
471
461
|
}
|
|
472
462
|
|
|
473
463
|
/** Visible peer records (excluding self), e.g. for command completions. */
|
|
474
|
-
async listPeers(): Promise<
|
|
464
|
+
async listPeers(): Promise<AgentRecord[]> {
|
|
475
465
|
const self = this.requireSelf();
|
|
476
466
|
const records = await listRecords(this.storage);
|
|
477
|
-
|
|
467
|
+
const memberIds = await this.myGroupMemberIds();
|
|
468
|
+
return records.filter((r) => r.addr !== self.addr && (memberIds?.has(r.agentId) ?? false));
|
|
478
469
|
}
|
|
479
470
|
|
|
480
471
|
/**
|
|
481
|
-
* Mark
|
|
472
|
+
* Mark an agent as dead: set its offline flag and pin lastSeenAt to 0 so
|
|
482
473
|
* the next sweep reaps it (empty mailbox). Without a target, marks this
|
|
483
|
-
*
|
|
474
|
+
* agent — later writeSelf calls no longer refresh lastSeenAt, and the
|
|
484
475
|
* record stays offline for peers.
|
|
485
476
|
*/
|
|
486
477
|
async markDead(target?: string): Promise<string> {
|
|
487
478
|
if (!target) {
|
|
488
479
|
this.dead = true;
|
|
489
480
|
await this.writeSelf({ offline: true });
|
|
490
|
-
return "Marked this
|
|
481
|
+
return "Marked this agent as dead.";
|
|
491
482
|
}
|
|
492
483
|
const resolved = await this.resolveTarget(target);
|
|
493
484
|
if (!resolved.ok) return resolved.error;
|
|
@@ -501,14 +492,134 @@ export class TalkCore {
|
|
|
501
492
|
for (const peer of peers) {
|
|
502
493
|
await writeRecord(this.storage, { ...peer, lastSeenAt: 0, offline: true });
|
|
503
494
|
}
|
|
504
|
-
return `Marked ${peers.length}
|
|
495
|
+
return `Marked ${peers.length} agent(s) as dead.`;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Groups ─────────────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/** Remove the caller from its current group, deleting the group when it empties. */
|
|
501
|
+
private async leaveCurrentGroup(): Promise<boolean> {
|
|
502
|
+
const self = this.requireSelf();
|
|
503
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
504
|
+
if (!group) return false;
|
|
505
|
+
const others = group.members.filter((m) => m !== self.agentId);
|
|
506
|
+
if (others.length === 0) {
|
|
507
|
+
await deleteGroup(this.storage, group.id);
|
|
508
|
+
} else {
|
|
509
|
+
await writeGroup(this.storage, { ...group, members: others, updatedAt: this.now() });
|
|
510
|
+
}
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Join or create a group and join it. With no name a fresh uuid is
|
|
516
|
+
* generated; with a name, the group is joined when it exists and created
|
|
517
|
+
* otherwise. Leaving any current group first keeps the single-group
|
|
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
|
+
*/
|
|
522
|
+
async groupJoin(groupName?: string, agentName?: string): Promise<string> {
|
|
523
|
+
const self = this.requireSelf();
|
|
524
|
+
const name =
|
|
525
|
+
groupName === undefined || groupName.trim() === "" ? newGroupId() : groupName.trim();
|
|
526
|
+
if (!isValidGroupName(name)) {
|
|
527
|
+
return `Invalid group name '${name}'. Allowed: letters, digits, '-' and '_' (max 64 chars).`;
|
|
528
|
+
}
|
|
529
|
+
if (agentName !== undefined) {
|
|
530
|
+
await this.writeSelf({ name: agentName });
|
|
531
|
+
}
|
|
532
|
+
const nameNote = agentName === undefined ? "" : ` You are visible as "${agentName}".`;
|
|
533
|
+
const existing = await readGroup(this.storage, name);
|
|
534
|
+
if (existing?.members.includes(self.agentId)) {
|
|
535
|
+
return `Already in group ${name} (${existing.members.length} member(s)).${nameNote}`;
|
|
536
|
+
}
|
|
537
|
+
await this.leaveCurrentGroup();
|
|
538
|
+
if (existing) {
|
|
539
|
+
await writeGroup(this.storage, {
|
|
540
|
+
...existing,
|
|
541
|
+
members: [...existing.members, self.agentId],
|
|
542
|
+
updatedAt: this.now(),
|
|
543
|
+
});
|
|
544
|
+
return `Joined group ${name} (${existing.members.length + 1} member(s)). You now see only co-members.${nameNote}`;
|
|
545
|
+
}
|
|
546
|
+
const now = this.now();
|
|
547
|
+
await writeGroup(this.storage, {
|
|
548
|
+
id: name,
|
|
549
|
+
members: [self.agentId],
|
|
550
|
+
createdAt: now,
|
|
551
|
+
updatedAt: now,
|
|
552
|
+
});
|
|
553
|
+
return `Created group ${name}.${nameNote} Other agents join it with /talk-group-join ${name}.`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Join the most recently created group; no-op when already in it. */
|
|
557
|
+
async groupJoinLast(): Promise<string> {
|
|
558
|
+
const groups = await listGroups(this.storage);
|
|
559
|
+
if (groups.length === 0) return "No groups. Create one with /talk-group-join.";
|
|
560
|
+
const latest = groups.reduce((a, b) => (b.createdAt > a.createdAt ? b : a));
|
|
561
|
+
return this.groupJoin(latest.id);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Leave the current group; an emptied group is deleted. */
|
|
565
|
+
async groupLeave(): Promise<string> {
|
|
566
|
+
const self = this.requireSelf();
|
|
567
|
+
const group = await groupForAgent(this.storage, self.agentId);
|
|
568
|
+
if (!group) return "Not in any group.";
|
|
569
|
+
const others = group.members.filter((m) => m !== self.agentId);
|
|
570
|
+
if (others.length === 0) {
|
|
571
|
+
await deleteGroup(this.storage, group.id);
|
|
572
|
+
return `Left group ${group.id} (deleted — it was empty).`;
|
|
573
|
+
}
|
|
574
|
+
await writeGroup(this.storage, { ...group, members: others, updatedAt: this.now() });
|
|
575
|
+
return `Left group ${group.id} (${others.length} member(s) remain).`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Delete a group by name; its members become ungrouped. */
|
|
579
|
+
async groupDelete(groupName: string): Promise<string> {
|
|
580
|
+
const name = groupName.trim();
|
|
581
|
+
if (!isValidGroupName(name)) return `Invalid group name '${name}'.`;
|
|
582
|
+
const group = await readGroup(this.storage, name);
|
|
583
|
+
if (!group) return `Unknown group '${name}'. Run /talk-group-list to see groups.`;
|
|
584
|
+
await deleteGroup(this.storage, name);
|
|
585
|
+
return `Deleted group ${name} (${group.members.length} member(s)).`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Delete every group; all agents become ungrouped. */
|
|
589
|
+
async groupClear(): Promise<string> {
|
|
590
|
+
const groups = await listGroups(this.storage);
|
|
591
|
+
for (const group of groups) await deleteGroup(this.storage, group.id);
|
|
592
|
+
return groups.length === 0 ? "No groups." : `Deleted ${groups.length} group(s).`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Human-readable listing of every group and its members (management view).
|
|
597
|
+
* Newest first — the oldest group is listed last.
|
|
598
|
+
*/
|
|
599
|
+
async groupList(): Promise<string> {
|
|
600
|
+
const self = this.requireSelf();
|
|
601
|
+
const groups = await listGroups(this.storage);
|
|
602
|
+
if (groups.length === 0) return "No groups.";
|
|
603
|
+
const records = await listRecords(this.storage);
|
|
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})`;
|
|
608
|
+
};
|
|
609
|
+
const lines = groups
|
|
610
|
+
.toSorted((a, b) => b.createdAt - a.createdAt)
|
|
611
|
+
.map((g) => {
|
|
612
|
+
const members = g.members.map((m) => (m === self.agentId ? `${label(m)} ← you` : label(m)));
|
|
613
|
+
return `- ${g.id} (created ${age(g.createdAt)}): ${members.join(", ")}`;
|
|
614
|
+
});
|
|
615
|
+
return `Groups (${groups.length}):\n${lines.join("\n")}`;
|
|
505
616
|
}
|
|
506
617
|
|
|
507
618
|
async send(to: string, body: string): Promise<string> {
|
|
508
619
|
if (!to) return 'send requires "to".';
|
|
509
620
|
if (!body) return 'send requires "message".';
|
|
510
621
|
if (to === "*" || to === "cwd") {
|
|
511
|
-
return "send requires a single
|
|
622
|
+
return "send requires a single agent id; broadcasting is disabled.";
|
|
512
623
|
}
|
|
513
624
|
const resolved = await this.resolveTarget(to);
|
|
514
625
|
if (!resolved.ok) return resolved.error;
|
|
@@ -574,10 +685,10 @@ export class TalkCore {
|
|
|
574
685
|
}
|
|
575
686
|
|
|
576
687
|
/** Build a minimal record from a letter's sender when the peer record is gone. */
|
|
577
|
-
private recordFromLetter(letter: Letter):
|
|
688
|
+
private recordFromLetter(letter: Letter): AgentRecord {
|
|
578
689
|
return {
|
|
579
690
|
addr: letter.from.addr,
|
|
580
|
-
|
|
691
|
+
agentId: letter.from.agentId,
|
|
581
692
|
name: letter.from.name,
|
|
582
693
|
cwd: letter.from.cwd,
|
|
583
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
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
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
|
+
* TalkStorage, never on pi.
|
|
5
|
+
*
|
|
6
|
+
* Rules:
|
|
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
|
+
* member can leave freely. There is no owner.
|
|
10
|
+
* - Visibility is fully group-driven: a grouped agent sees only its
|
|
11
|
+
* co-members; an agent in no group sees only itself.
|
|
12
|
+
* - A group that empties is deleted; a one-member group is a normal state
|
|
13
|
+
* (a creator waiting for peers to join).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
|
|
18
|
+
import { type Static, Type } from "typebox";
|
|
19
|
+
import { Value } from "typebox/value";
|
|
20
|
+
|
|
21
|
+
import type { TalkStorage } from "./storage.js";
|
|
22
|
+
|
|
23
|
+
export const GroupSchema = Type.Object({
|
|
24
|
+
id: Type.String(),
|
|
25
|
+
/** pi agent uuids of the members. */
|
|
26
|
+
members: Type.Array(Type.String()),
|
|
27
|
+
createdAt: Type.Number(),
|
|
28
|
+
updatedAt: Type.Number(),
|
|
29
|
+
});
|
|
30
|
+
export type Group = Static<typeof GroupSchema>;
|
|
31
|
+
|
|
32
|
+
export const GROUPS_NS = "groups";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Group names are user-facing and become storage keys, so they are
|
|
36
|
+
* constrained: start with a letter/digit, then letters, digits, '-' or '_'.
|
|
37
|
+
* A generated uuid fits this pattern too.
|
|
38
|
+
*/
|
|
39
|
+
const GROUP_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
40
|
+
|
|
41
|
+
export function assertGroupId(id: string): void {
|
|
42
|
+
if (!GROUP_ID_PATTERN.test(id)) throw new TypeError(`Invalid group name: ${id}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isValidGroupName(id: string): boolean {
|
|
46
|
+
return GROUP_ID_PATTERN.test(id);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function newGroupId(): string {
|
|
50
|
+
return randomUUID();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function groupKey(id: string): string {
|
|
54
|
+
assertGroupId(id);
|
|
55
|
+
return `${id}.json`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** All groups, oldest first. Never mutates anything. */
|
|
59
|
+
export async function listGroups(storage: TalkStorage): Promise<Group[]> {
|
|
60
|
+
const out: Group[] = [];
|
|
61
|
+
for (const key of await storage.listKeys(GROUPS_NS)) {
|
|
62
|
+
const raw = await storage.readJson(GROUPS_NS, key);
|
|
63
|
+
if (Value.Check(GroupSchema, raw)) out.push(raw);
|
|
64
|
+
}
|
|
65
|
+
return out.toSorted((a, b) => a.createdAt - b.createdAt);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function readGroup(storage: TalkStorage, id: string): Promise<Group | null> {
|
|
69
|
+
const raw = await storage.readJson(GROUPS_NS, groupKey(id));
|
|
70
|
+
return Value.Check(GroupSchema, raw) ? raw : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeGroup(storage: TalkStorage, group: Group): Promise<void> {
|
|
74
|
+
await storage.writeJson(GROUPS_NS, groupKey(group.id), group);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deleteGroup(storage: TalkStorage, id: string): Promise<boolean> {
|
|
78
|
+
return storage.removeKey(GROUPS_NS, groupKey(id));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
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
|
+
* groups resolves to the first match.
|
|
85
|
+
*/
|
|
86
|
+
export async function groupForAgent(storage: TalkStorage, agentId: string): Promise<Group | null> {
|
|
87
|
+
for (const group of await listGroups(storage)) {
|
|
88
|
+
if (group.members.includes(agentId)) return group;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|