pi-roundtable 0.8.0 → 0.9.1

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.
Files changed (158) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +618 -86
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +150 -86
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +42 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -17,6 +17,7 @@ import type { LinkedSessions } from "../plugin.ts";
17
17
  import {
18
18
  planOrder,
19
19
  type SessionContext,
20
+ type SessionConversation,
20
21
  type SessionPlan,
21
22
  type ToolSelection,
22
23
  type TransientTask,
@@ -24,7 +25,8 @@ import {
24
25
  import { activeToolsExtension } from "../shared/active-tools.ts";
25
26
  import { packageDir } from "../shared/package-dir.ts";
26
27
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
27
- import { addressee, type Speaker, THE_SPEAKER } from "../speakers.ts";
28
+ import { type Speaker, THE_SPEAKER } from "../speakers.ts";
29
+ import { currentToolNames } from "../tool-tiers.ts";
28
30
  import {
29
31
  CompactionTiers,
30
32
  compactionEngine,
@@ -35,6 +37,11 @@ import {
35
37
  type ConfirmationGate,
36
38
  confirmationGateExtension,
37
39
  } from "./extensions/confirmation-gate.ts";
40
+ import {
41
+ MemoryDraws,
42
+ privateCompaction,
43
+ privateMemoryExtension,
44
+ } from "./extensions/private-memory.ts";
38
45
  import {
39
46
  COMPACT_TOOL,
40
47
  selfCompactGuardExtension,
@@ -51,13 +58,26 @@ import {
51
58
  sessionExtensions,
52
59
  skillsKey,
53
60
  } from "./runtime-types.ts";
61
+ import {
62
+ memoryReader,
63
+ sessionAddressee,
64
+ turnAddressee,
65
+ } from "./session-conversation.ts";
54
66
 
55
67
  /** What a session factory asks of the runtime that owns the turns. */
56
68
  export interface SessionFactoryDeps {
57
69
  /** The person the conversation's running turn is for. */
58
70
  speaker(channel: ChannelKey): Speaker | undefined;
59
- /** Runs a task beside the conversation, under its gate. */
60
- runTask(channel: ChannelKey, task: TransientTask): Promise<string>;
71
+ /** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
72
+ withholdsMemory(channel: ChannelKey): boolean;
73
+ /**
74
+ * Runs a task beside the conversation of `home`, under its gate, at the tier of the running
75
+ * turn of `turn`: the conversation's own channel, or an agent's seat in a group.
76
+ */
77
+ runTask(
78
+ scope: { turn: ChannelKey; home: ChannelKey },
79
+ task: TransientTask,
80
+ ): Promise<string>;
61
81
  }
62
82
 
63
83
  /** Builds Pi sessions for the runtime: one resource loader per session, its extensions in order. */
@@ -107,7 +127,7 @@ export class SessionFactory {
107
127
  (tool) => tool.snapshot().groups ?? [],
108
128
  );
109
129
  return [
110
- ...selection.tools,
130
+ ...currentToolNames(selection.tools, this.#options.logger),
111
131
  COMPACT_TOOL,
112
132
  ASK_USER_TOOL,
113
133
  ...groups
@@ -116,6 +136,30 @@ export class SessionFactory {
116
136
  ];
117
137
  }
118
138
 
139
+ /** The tools of a task beside a turn: its selection without what it excludes, and it never asks. */
140
+ taskTools(selection: ToolSelection, exclude: readonly string[]): string[] {
141
+ const excluded = new Set([
142
+ ...currentToolNames(exclude, this.#options.logger),
143
+ ASK_USER_TOOL,
144
+ ]);
145
+ return this.toolsFor(selection).filter((name) => !excluded.has(name));
146
+ }
147
+
148
+ /** Every tool startup refuses to run without: the plugins', the core's, and the session tools'. */
149
+ requiredTools(): string[] {
150
+ return currentToolNames(
151
+ [
152
+ ...this.link().requiredTools,
153
+ COMPACT_TOOL,
154
+ ASK_USER_TOOL,
155
+ ...planOrder(this.plan).flatMap(
156
+ (tool) => tool.snapshot().requiredTools ?? [],
157
+ ),
158
+ ],
159
+ this.#options.logger,
160
+ );
161
+ }
162
+
119
163
  /** Waits for the tools the session tools register late, such as pi-mcp-adapter's after its eager connection. */
120
164
  async #awaitTools(
121
165
  session: AgentSession,
@@ -212,15 +256,41 @@ export class SessionFactory {
212
256
  attachmentDir: string,
213
257
  agent: AgentTurnScope | undefined,
214
258
  kind: string,
259
+ /** Whom the conversation serves, fixed for the session's life. */
260
+ conversation: SessionConversation,
261
+ /**
262
+ * A worker's: the conversation whose running turn it acts in, and that conversation's memory
263
+ * policy, which it keeps rather than reading its own kind's.
264
+ */
265
+ worker?: { turn: ChannelKey; memory: SessionContext["memory"] },
215
266
  ): Promise<ChannelSession> {
216
267
  const { agentDir, model, thinking, logger } = this.#options;
217
268
  // An agent's prompt is set before each run; the other kinds are refused here, before a session is built.
218
269
  const persona = agent ? undefined : this.personaOf(kind);
219
270
  const skills = this.skillsOf(agent);
271
+ const addressee = agent
272
+ ? THE_SPEAKER
273
+ : sessionAddressee(conversation, this.#options.owner);
274
+ // An agent's conversations read each speaker's memory; another kind's, as its persona says;
275
+ // a worker's, as the conversation it works for.
276
+ const memory =
277
+ worker?.memory ??
278
+ (agent ? "speaker" : (this.link().personaMemory?.(kind) ?? "speaker"));
279
+ // The running turn is the conversation's: an agent's seat in a group, or its own channel; a
280
+ // worker's is the turn that started it.
281
+ const turnKey = worker?.turn ?? agent?.session ?? channel;
282
+ const deps = this.#deps;
220
283
  const state = {
221
284
  tools: [] as readonly string[],
222
285
  revisions: revisionsKey(this.plan),
223
286
  skills: skillsKey(skills),
287
+ conversation,
288
+ addressee,
289
+ // A turn the agent server keeps out of memory reads none, and so does a worker beside it.
290
+ get memory(): SessionContext["memory"] {
291
+ return deps.withholdsMemory(turnKey) ? "none" : memory;
292
+ },
293
+ draws: new MemoryDraws(),
224
294
  };
225
295
  const awaited = planOrder(this.plan).flatMap(
226
296
  (tool) => tool.snapshot().awaitTools ?? [],
@@ -240,16 +310,34 @@ export class SessionFactory {
240
310
  homeChannel: channel,
241
311
  turnChannel: agent && agents ? agents.turnChannel(agent) : channel,
242
312
  compaction: {
313
+ // A shared conversation's compactor summarizes no one's private memory.
243
314
  wrap: (compactor) =>
244
- tiers.wrapCompactor(compactor, (bypass) =>
245
- logger.info(
246
- { channel, ...bypass, ceiling: HARD_COMPACT_TOKENS },
247
- "compaction skips the extension for Pi's summary",
248
- ),
315
+ tiers.wrapCompactor(
316
+ compactor,
317
+ (bypass) =>
318
+ logger.info(
319
+ { channel, ...bypass, ceiling: HARD_COMPACT_TOKENS },
320
+ "compaction skips the extension for Pi's summary",
321
+ ),
322
+ conversation.visibility === "shared"
323
+ ? (event) =>
324
+ privateCompaction(
325
+ event.preparation,
326
+ event.branchEntries.flatMap((entry) =>
327
+ entry.type === "message" ? [entry.message] : [],
328
+ ),
329
+ )
330
+ : undefined,
249
331
  ),
250
332
  },
251
- speaker: () => this.#deps.speaker(channel),
252
- runTask: (task) => this.#deps.runTask(channel, task),
333
+ conversation,
334
+ addressee,
335
+ get memory() {
336
+ return state.memory;
337
+ },
338
+ speaker: () => this.#deps.speaker(turnKey),
339
+ runTask: (task) =>
340
+ this.#deps.runTask({ turn: turnKey, home: channel }, task),
253
341
  };
254
342
  if (agent) context.agent = agent;
255
343
  const resourceLoader = new DefaultResourceLoader({
@@ -265,12 +353,23 @@ export class SessionFactory {
265
353
  extensionFactories: sessionExtensions(this.plan, context, {
266
354
  readAttachment: readAttachmentExtension(attachmentDir),
267
355
  confirmationGate: confirmationGateExtension(gate, slot),
268
- askUser: askUserExtension(
269
- slot,
270
- agent ? THE_SPEAKER : this.#options.owner,
271
- () => addressee(this.#deps.speaker(channel), this.#options.owner),
356
+ askUser: askUserExtension(slot, addressee, () =>
357
+ turnAddressee(
358
+ this.#deps.speaker(turnKey),
359
+ state,
360
+ this.#options.owner,
361
+ ),
272
362
  ),
273
363
  selfCompactGuard: selfCompactGuardExtension(),
364
+ // Each request carries no one's memory but the running turn's reader's.
365
+ privateMemory: privateMemoryExtension(
366
+ conversation.visibility === "shared",
367
+ () =>
368
+ deps.withholdsMemory(turnKey)
369
+ ? undefined
370
+ : memoryReader(conversation, deps.speaker(turnKey)),
371
+ state.draws,
372
+ ),
274
373
  activeTools: activeToolsExtension(() => state.tools),
275
374
  }),
276
375
  // An agent's prompt is set before each run by the agent-prompt extension.
@@ -314,6 +413,7 @@ export class SessionFactory {
314
413
 
315
414
  /** Pi's bash tool with TMPDIR set to `scratchDir`, so `mktemp` and tools write there. */
316
415
  export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
416
+ // SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
317
417
  return createBashToolDefinition(cwd, {
318
418
  spawnHook: (context) => ({
319
419
  ...context,
@@ -0,0 +1,78 @@
1
+ import { existsSync } from "node:fs";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import type { TurnConversation } from "../domain/ports.ts";
4
+ import { archiveSessions } from "./session-archive.ts";
5
+
6
+ /** The custom entry a session's history records whom it served in. */
7
+ export const SCOPE_ENTRY = "roundtable-conversation";
8
+
9
+ /** Whom a conversation serves, as a session's history records it. */
10
+ type Scope = Pick<TurnConversation, "visibility"> & { principalId?: string };
11
+
12
+ function scopeOf(conversation: Scope): TurnConversation {
13
+ return conversation.visibility === "private" &&
14
+ conversation.principalId !== undefined
15
+ ? { visibility: "private", principalId: conversation.principalId }
16
+ : { visibility: "shared" };
17
+ }
18
+
19
+ /** Whether two scopes are the same: shared both, or private to the same principal. */
20
+ export function sameScope(a: Scope, b: Scope): boolean {
21
+ return JSON.stringify(scopeOf(a)) === JSON.stringify(scopeOf(b));
22
+ }
23
+
24
+ /** The scope the history last recorded, or undefined when it records none, as before 0.9. */
25
+ function recordedScope(manager: SessionManager): TurnConversation | undefined {
26
+ const entry = manager
27
+ .getEntries()
28
+ .findLast(
29
+ (entry) => entry.type === "custom" && entry.customType === SCOPE_ENTRY,
30
+ );
31
+ return entry?.type === "custom"
32
+ ? scopeOf(entry.data as TurnConversation)
33
+ : undefined;
34
+ }
35
+
36
+ /**
37
+ * The latest history in `dir` for a session of the scope. A history recorded in another scope is
38
+ * archived, so the session starts with none: one person's conversation never replays for someone
39
+ * else, nor a private one in a shared one or the other way. A history that records no scope, as
40
+ * one made before 0.9, carries on. The history the session continues records its scope.
41
+ */
42
+ export function scopedHistory(
43
+ dir: string,
44
+ cwd: string,
45
+ scope: Scope,
46
+ ): { history: SessionManager; archived: number } {
47
+ let history = SessionManager.continueRecent(cwd, dir);
48
+ const recorded = recordedScope(history);
49
+ let archived = 0;
50
+ if (recorded && !sameScope(recorded, scope)) {
51
+ archived = archiveSessions(dir);
52
+ history = SessionManager.continueRecent(cwd, dir);
53
+ }
54
+ if (!recorded || archived > 0)
55
+ history.appendCustomEntry(SCOPE_ENTRY, scopeOf(scope));
56
+ return { history, archived };
57
+ }
58
+
59
+ /** The scope the latest history in `dir` records, if any. */
60
+ export function historyScope(
61
+ dir: string,
62
+ cwd: string,
63
+ ): TurnConversation | undefined {
64
+ if (!existsSync(dir)) return undefined;
65
+ return recordedScope(SessionManager.continueRecent(cwd, dir));
66
+ }
67
+
68
+ /**
69
+ * The messages of the latest history in `dir`, read without opening a session, so reading them
70
+ * fixes no scope; none when it was recorded in another scope than `scope`.
71
+ */
72
+ export function historyMessages(dir: string, cwd: string, scope: Scope) {
73
+ if (!existsSync(dir)) return [];
74
+ const history = SessionManager.continueRecent(cwd, dir);
75
+ const recorded = recordedScope(history);
76
+ if (recorded && !sameScope(recorded, scope)) return [];
77
+ return history.buildSessionContext().messages;
78
+ }
@@ -1,7 +1,16 @@
1
1
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type { TurnAttachments } from "../domain/attachment.ts";
2
3
 
3
4
  export type PromptImages = NonNullable<Parameters<AgentSession["steer"]>[1]>;
4
5
 
6
+ /** A turn's images as Pi takes them with a prompt or a steer. */
7
+ export function promptImages(attachments: TurnAttachments): PromptImages {
8
+ return attachments.images.map((image) => ({
9
+ type: "image" as const,
10
+ ...image,
11
+ }));
12
+ }
13
+
5
14
  /** The part of a Pi session a steerable run drives. */
6
15
  export type SteeringSession = Pick<
7
16
  AgentSession,
@@ -24,13 +33,22 @@ export class SteerableRun {
24
33
  readonly #canSteer: () => boolean;
25
34
  /** Steered prompts in arrival order, each with its images. */
26
35
  readonly #steers: { text: string; images: PromptImages }[] = [];
36
+ readonly #abort: () => void;
27
37
  #stopped = false;
28
38
  #done = false;
29
39
 
30
- /** `canSteer` is asked at each steer: the turn is the owner's and holds no actions. */
31
- constructor(session: SteeringSession, canSteer: () => boolean) {
40
+ /**
41
+ * `canSteer` is asked at each steer: the turn is the owner's and holds no actions. `abort`
42
+ * ends the session's run when the turn is stopped; by default the session's own abort.
43
+ */
44
+ constructor(
45
+ session: SteeringSession,
46
+ canSteer: () => boolean,
47
+ abort: () => void = () => void session.abort(),
48
+ ) {
32
49
  this.#session = session;
33
50
  this.#canSteer = canSteer;
51
+ this.#abort = abort;
34
52
  }
35
53
 
36
54
  get stopped(): boolean {
@@ -97,7 +115,7 @@ export class SteerableRun {
97
115
  if (this.#done) return false;
98
116
  this.#stopped = true;
99
117
  this.#session.clearQueue();
100
- void this.#session.abort();
118
+ this.#abort();
101
119
  return true;
102
120
  }
103
121
 
@@ -0,0 +1,47 @@
1
+ import type {
2
+ AgentSession,
3
+ ModelRuntime,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import type { ChannelKey } from "../domain/conversation.ts";
6
+ import type { TurnRequest } from "../domain/ports.ts";
7
+ import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
8
+ import {
9
+ type AgentModelSettings,
10
+ useChosenAgentModel,
11
+ } from "./bridge-guard.ts";
12
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
13
+ import { lastReply } from "./turn-answer.ts";
14
+
15
+ /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
16
+ export async function thinkingLevel(
17
+ parts: {
18
+ modelRuntime: ModelRuntime;
19
+ effort: PiAgentRuntimeOptions["effort"];
20
+ /** The level last judged for each conversation. */
21
+ judged: Map<ChannelKey, ThinkingLevel>;
22
+ },
23
+ key: ChannelKey,
24
+ session: AgentSession,
25
+ request: TurnRequest,
26
+ agentModel?: AgentModelSettings,
27
+ ): Promise<ThinkingLevel> {
28
+ const { modelRuntime, effort, judged } = parts;
29
+ const setting = request.agent
30
+ ? await useChosenAgentModel(
31
+ session,
32
+ modelRuntime,
33
+ request.agent.name,
34
+ agentModel,
35
+ )
36
+ : AUTO_THINKING;
37
+ if (setting !== AUTO_THINKING) {
38
+ judged.delete(key);
39
+ return setting;
40
+ }
41
+ const level = await effort.judge(request.text, {
42
+ reply: lastReply(session.messages),
43
+ level: judged.get(key),
44
+ });
45
+ judged.set(key, level);
46
+ return level;
47
+ }
@@ -0,0 +1,43 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type {
3
+ ExtensionAPI,
4
+ ExtensionFactory,
5
+ ToolDefinition,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ /** The ids of the tool calls whose code is running, outermost first. */
9
+ const calls = new AsyncLocalStorage<readonly string[]>();
10
+
11
+ /**
12
+ * The ids of the tool calls the code asking runs within, outermost first: the call of a session
13
+ * part's tool, and the calls it runs within in turn, such as a worker's call inside the call that
14
+ * started the worker. Empty outside every such call.
15
+ */
16
+ export function runningCalls(): readonly string[] {
17
+ return calls.getStore() ?? [];
18
+ }
19
+
20
+ /**
21
+ * A session part's extension whose tools each run within their call's id, so what a call starts,
22
+ * a task's worker among them, knows which call it serves rather than every call running at once.
23
+ */
24
+ export function scopedCalls(factory: ExtensionFactory): ExtensionFactory {
25
+ return (pi) =>
26
+ factory(
27
+ new Proxy(pi, {
28
+ get(target, key) {
29
+ if (key === "registerTool")
30
+ return (tool: ToolDefinition) =>
31
+ target.registerTool({
32
+ ...tool,
33
+ execute: (toolCallId, ...rest) =>
34
+ calls.run([...runningCalls(), toolCallId], () =>
35
+ tool.execute(toolCallId, ...rest),
36
+ ),
37
+ });
38
+ const value: unknown = Reflect.get(target, key, target);
39
+ return typeof value === "function" ? value.bind(target) : value;
40
+ },
41
+ }) satisfies ExtensionAPI,
42
+ );
43
+ }
@@ -1,7 +1,8 @@
1
- import type { TurnResult } from "../domain/conversation.ts";
1
+ import type { TranscriptEntry, TurnResult } from "../domain/conversation.ts";
2
2
  import { AgentRunError } from "../domain/errors.ts";
3
3
  import { hasReplyFiles } from "../reply-files.ts";
4
4
  import { lastAssistant, textOf } from "../shared/session-messages.ts";
5
+ import { TRANSCRIPT_ENTRY_CHARS } from "./runtime-types.ts";
5
6
 
6
7
  interface MessagePart {
7
8
  type?: unknown;
@@ -20,6 +21,34 @@ type Assistant = Message & {
20
21
  const isAssistant = (message: Message): message is Assistant =>
21
22
  message.role === "assistant";
22
23
 
24
+ /** The answer to a turn sent without a speaker: refused, never run as the owner's by default. */
25
+ export function unspokenTurn(): TurnResult {
26
+ return {
27
+ ok: false,
28
+ error: new AgentRunError(
29
+ "a turn needs a speaker: pass TurnRequest.speaker, the person the turn is for; for a turn started on someone's behalf, get theirs from IDENTITY.speakerFor",
30
+ ),
31
+ };
32
+ }
33
+
34
+ /** The last `limit` user and assistant messages as transcript entries, each cut to its first characters. */
35
+ export function transcriptOf(
36
+ messages: readonly (Message & { content?: unknown })[],
37
+ limit: number,
38
+ ): TranscriptEntry[] {
39
+ const entries: TranscriptEntry[] = [];
40
+ for (const message of messages) {
41
+ if (message.role !== "user" && message.role !== "assistant") continue;
42
+ const text = textOf(message.content).trim();
43
+ if (text)
44
+ entries.push({
45
+ role: message.role,
46
+ text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
47
+ });
48
+ }
49
+ return entries.slice(-limit);
50
+ }
51
+
23
52
  /** The session's last answer, which tells the judge what a short follow-up continues. */
24
53
  export function lastReply(messages: readonly Message[]): string | undefined {
25
54
  const last = messages.findLast(isAssistant);
@@ -0,0 +1,63 @@
1
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
+ import type { ChannelKey } from "../domain/conversation.ts";
3
+ import type { TurnRequest } from "../domain/ports.ts";
4
+ import type { ConfirmationGate } from "./extensions/confirmation-gate.ts";
5
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
6
+
7
+ /**
8
+ * What a turn leaves behind when it ends, whatever way it ended: the gate stops counting the turn,
9
+ * the conversation's context usage and held actions are recorded, and the turn is logged. A
10
+ * session disposed of under a turn that would not stop has no usage to read.
11
+ */
12
+ export async function wrapUpTurn(parts: {
13
+ key: ChannelKey;
14
+ request: TurnRequest;
15
+ session: AgentSession;
16
+ gate: ConfirmationGate;
17
+ toolCalls: readonly string[];
18
+ options: Pick<PiAgentRuntimeOptions, "confirmations" | "logger">;
19
+ recordUsage: (usage: {
20
+ tokens: number | null;
21
+ contextWindow: number;
22
+ }) => void;
23
+ }): Promise<void> {
24
+ const { key, request, session, gate, toolCalls, options, recordUsage } =
25
+ parts;
26
+ const { logger } = options;
27
+ gate.endTurn();
28
+ try {
29
+ const usage = session.getContextUsage();
30
+ if (usage)
31
+ recordUsage({
32
+ tokens: usage.tokens,
33
+ contextWindow: usage.contextWindow,
34
+ });
35
+ } catch (error) {
36
+ logger.warn({ channel: key, err: error }, "context usage not read");
37
+ }
38
+ const held = gate.pending();
39
+ await options.confirmations
40
+ .save(key, held)
41
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
42
+ .catch((error: unknown) =>
43
+ logger.error({ channel: key, err: error }, "held actions not saved"),
44
+ );
45
+ if (held)
46
+ logger.info(
47
+ { channel: key, held: held.calls.map((call) => call.action) },
48
+ "actions held for confirmation",
49
+ );
50
+ logger.info(
51
+ {
52
+ channel: key,
53
+ selection: request.selection.id,
54
+ toolCalls,
55
+ thinking: session.thinkingLevel,
56
+ // pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
57
+ ...(request.agent
58
+ ? { model: `${session.model?.provider}/${session.model?.id}` }
59
+ : {}),
60
+ },
61
+ "turn finished",
62
+ );
63
+ }
@@ -1,12 +1,16 @@
1
1
  import type { Agent, AgentGroup } from "./agents/agent-rules.ts";
2
2
  import type { AvatarMode } from "./agents/agent-tools.ts";
3
3
  import type { TeamStatus } from "./agents/team-status.ts";
4
- import type { ScheduledOutcome } from "./contract/channels.ts";
4
+ import type {
5
+ BackgroundRunsAs,
6
+ ScheduledOutcome,
7
+ } from "./contract/channels.ts";
5
8
  import type { AgentRuntime } from "./contract/runtime.ts";
6
9
  import { type ServiceKey, serviceKey } from "./contract/services.ts";
7
10
  import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
8
11
  import type { PendingConfirmation } from "./domain/conversation.ts";
9
12
  import type { HttpRoute } from "./http/listeners.ts";
13
+ import type { IdentityService } from "./identity/identity-service.ts";
10
14
  import type { ThinkingSetting } from "./models.ts";
11
15
  import type {
12
16
  DelegationJob,
@@ -36,6 +40,12 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
36
40
  // interface, so an object with the same methods satisfies it without being one of the built-in
37
41
  // classes; a plugin replaces a built-in by providing its own under the same key.
38
42
 
43
+ /**
44
+ * Who the host serves: its principals, the identities linked to each, and their lasting roles.
45
+ * Provided by the `identity` plugin, which every host registers first, so every plugin may read it.
46
+ */
47
+ export const IDENTITY: ServiceKey<IdentityService> =
48
+ serviceKey<IdentityService>("roundtable.identity");
39
49
  /**
40
50
  * The runtime every conversation turn runs on: the agent server's, and each turn run through
41
51
  * `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
@@ -178,6 +188,16 @@ export interface AgentTeam {
178
188
  /** Scheduled turns, stored. A one-time schedule is deleted once it fires. */
179
189
  export interface ScheduleStore {
180
190
  create(schedule: NewSchedule): Promise<Schedule>;
191
+ /**
192
+ * Creates a schedule unless its creator already has `limit.max` of its target's schedules,
193
+ * counting those whose creator id is one of `limit.creators`; resolves the count reached instead.
194
+ * Two calls for one creator never count at once, so creates at once in many conversations stay
195
+ * within the limit.
196
+ */
197
+ createWithin(
198
+ schedule: NewSchedule,
199
+ limit: { creators: readonly string[]; max: number },
200
+ ): Promise<{ created: Schedule } | { reached: number }>;
181
201
  get(id: number): Promise<Schedule | undefined>;
182
202
  forChannel(channel: ChannelKey): Promise<Schedule[]>;
183
203
  all(): Promise<Schedule[]>;
@@ -208,6 +228,12 @@ export interface BackgroundTurns {
208
228
  firedAt: Date,
209
229
  finding?: PrecheckFinding,
210
230
  ): Promise<ScheduledOutcome>;
231
+ /**
232
+ * Who a due schedule's turn would run as now, checked as the turn is: asked before its
233
+ * precheck, which runs only for a creator who may run the turn, at the tier it would run at.
234
+ * Throws when the identity service cannot tell.
235
+ */
236
+ runsAs(schedule: Schedule): Promise<BackgroundRunsAs>;
211
237
  /** A delegated task's report, answered in its channel under the same rules as a schedule. */
212
238
  runDelegated(job: DelegationJob, result: DelegationOutcome): Promise<void>;
213
239
  /** The process's own logged error, reported to an agent in its channel as a report turn. */
@@ -1,5 +1,7 @@
1
1
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
2
  import { PluginError } from "./errors.ts";
3
+ import type { Principal } from "./identity/principal-store.ts";
4
+ import type { OwnerIdentity } from "./identity.ts";
3
5
  import type { Speaker } from "./speakers.ts";
4
6
 
5
7
  /** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
@@ -41,6 +43,15 @@ export interface TransientTask {
41
43
  onFinished?(toolCalls: readonly string[]): void;
42
44
  }
43
45
 
46
+ /**
47
+ * Whom a session's conversation serves, fixed when the session is made: `private` to one
48
+ * principal, whose name and pronouns are known when the host has a record of them, or `shared`
49
+ * by whoever its claim admits, as an agent's session always is.
50
+ */
51
+ export type SessionConversation =
52
+ | { visibility: "private"; principalId: string; principal?: Principal }
53
+ | { visibility: "shared" };
54
+
44
55
  /** What one Pi session is for, as a session tool's factory sees it. */
45
56
  export interface SessionContext {
46
57
  /** "agent" for an agent's session; otherwise the kind of conversation, which the claim that owns the channel names. */
@@ -53,6 +64,25 @@ export interface SessionContext {
53
64
  agent?: AgentTurnScope;
54
65
  /** Wraps a compactor so the core's compaction tiers decide when it may answer. */
55
66
  compaction: { wrap(compactor: ExtensionFactory): ExtensionFactory };
67
+ /**
68
+ * Whom the conversation serves, as `TurnRequest.conversation` or the host's record says when the
69
+ * session is made; shared when neither says. A private conversation's tools serve its person:
70
+ * their memory, their notices. A shared one's serve each turn's speaker.
71
+ */
72
+ conversation: SessionConversation;
73
+ /**
74
+ * Whom the session's tool descriptions address, fixed when it is made: a private conversation's
75
+ * person by their name and pronouns, the primary owner exactly as 0.8 wrote them, and
76
+ * `THE_SPEAKER` in a shared conversation, where each turn's prompt says who speaks.
77
+ */
78
+ addressee: OwnerIdentity;
79
+ /**
80
+ * Whose memory the session's turns may read: `"speaker"` (the default), as `conversation` and
81
+ * each turn's speaker decide, or `"none"`, for a persona that declares it; then no memory loads.
82
+ * While the running turn is one the agent server keeps out of memory (`AgentSessions.memory`),
83
+ * it reads `"none"` for that turn alone.
84
+ */
85
+ memory: "speaker" | "none";
56
86
  /** The person the session's running turn is for; undefined between turns. */
57
87
  speaker(): Speaker | undefined;
58
88
  /** Runs a task beside this session, under its confirmation gate. */