@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/src/talk/core.ts CHANGED
@@ -1,18 +1,29 @@
1
1
  /**
2
- * Talk core: coordinates the registry, mailbox, and policy over a storage
3
- * backend, and yields deliveries and notifications to an adapter through
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 session (`events.deliver` returns true). A
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 { isAbsolute, resolve } from "node:path";
13
-
14
- import { expandHome } from "../lib/path.js";
15
- import { formatListing, refusalUnknown, shortAddr } from "./format.js";
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: SessionRecord } | { ok: false; error: string };
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 + sessionId` breaks the tie deterministically.
87
+ * collision, `cwd + agentId` breaks the tie deterministically.
103
88
  */
104
89
  export function peerAskedFirst(
105
- peer: { ts: number; cwd: string; sessionId: string },
106
- self: { ts: number; cwd: string; sessionId: 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.sessionId}`;
109
- const selfKey = `${self.cwd}\u0000${self.sessionId}`;
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: SessionRecord | undefined;
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
- /** Replace the peer-visibility gate (from the session's `.pi/talk.json`). */
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: SessionRecord): Promise<void> {
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 session
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 session's own first turn.
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
- setSessionName(name: string): void {
186
+ setAgentName(name: string): void {
209
187
  void this.writeSelf({ name });
210
188
  }
211
189
 
212
- private async writeSelf(patch: Partial<SessionRecord>): Promise<void> {
190
+ private async writeSelf(patch: Partial<AgentRecord>): Promise<void> {
213
191
  if (!this.self) return;
214
- // A dead session pins lastSeenAt to 0 so no later event re-freshens it.
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 session
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
- * Resolve a target by its exact session id (uuid). A peer must be visible
323
- * from this session (`setPeerVisibility`); invisible peers are unreachable
324
- * even with a known id.
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 others = records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
330
- const target = others.find((r) => r.sessionId === to);
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: SessionRecord,
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, sessionId: self.sessionId },
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, session dir + session id (carried in
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, sessionId: letter.from.sessionId },
433
- { ts: myAsk.ts, cwd: self.cwd, sessionId: self.sessionId },
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 sessions, including self (marked `self: true`).
448
- * Every visible record is listed, live or offline; presence decides the
449
- * per-session status.
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 this.isPeerVisible(r.cwd);
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 this.isPeerVisible(r.cwd);
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<SessionRecord[]> {
464
+ async listPeers(): Promise<AgentRecord[]> {
475
465
  const self = this.requireSelf();
476
466
  const records = await listRecords(this.storage);
477
- return records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
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 a session as dead: set its offline flag and pin lastSeenAt to 0 so
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
- * session — later writeSelf calls no longer refresh lastSeenAt, and the
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 session as dead.";
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} session(s) as dead.`;
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 session id; broadcasting is disabled.";
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): SessionRecord {
688
+ private recordFromLetter(letter: Letter): AgentRecord {
578
689
  return {
579
690
  addr: letter.from.addr,
580
- sessionId: letter.from.sessionId,
691
+ agentId: letter.from.agentId,
581
692
  name: letter.from.name,
582
693
  cwd: letter.from.cwd,
583
694
  pid: 0,
@@ -4,10 +4,9 @@
4
4
  */
5
5
 
6
6
  import type { Letter } from "./mailbox.js";
7
- import type { Presence, SessionRecord } from "./registry.js";
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 session's LLM context. */
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 session ${from.sessionId} (${from.cwd}) — "${from.name}"`;
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 session as the model sees it in a listing. `id` is the stable pi
35
- * session uuid; `name` is the display name when one was set; `self` marks
36
- * the calling session itself. */
37
- export interface SessionListItem {
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 sessions (what the model sees). */
44
+ /** Machine-readable JSON listing of agents (what the model sees). */
46
45
  export function formatListing(
47
- records: SessionRecord[],
46
+ records: AgentRecord[],
48
47
  selfAddr: string,
49
- presence: (r: SessionRecord) => Presence,
48
+ presence: (r: AgentRecord) => Presence,
50
49
  ): string {
51
50
  if (records.length === 0) return "[]";
52
- const items: SessionListItem[] = records.map((r) => {
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: SessionListItem = { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
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 session id '${to}'. Get session ids with talk-list-sessions.`;
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
+ }