pi-roundtable 0.7.19 → 0.9.0

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 (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,5 +1,4 @@
1
1
  import { rmSync } from "node:fs";
2
- import { join } from "node:path";
3
2
  import {
4
3
  type AgentSession,
5
4
  type ModelRuntime,
@@ -15,38 +14,47 @@ import type {
15
14
  TranscriptEntry,
16
15
  TurnResult,
17
16
  } from "../domain/conversation.ts";
18
- import { AgentRunError, ConfigError } from "../domain/errors.ts";
17
+ import { AgentRunError } from "../domain/errors.ts";
19
18
  import type { TurnRequest } from "../domain/ports.ts";
20
19
  import { assistantName } from "../i18n/index.ts";
21
- import {
22
- AUTO_THINKING,
23
- parseModelRef,
24
- type ThinkingLevel,
25
- type ThinkingSetting,
26
- } from "../models.ts";
20
+ import { promptScopeOf } from "../interactions/prompts.ts";
21
+ import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
22
+ import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
27
23
  import { withoutReplyFiles } from "../reply-files.ts";
28
- import { planOrder, type TransientTask } from "../sessions.ts";
29
- import { textOf } from "../shared/session-messages.ts";
30
- import { addressee, type Speaker, type Tier } from "../speakers.ts";
24
+ import type { TransientTask } from "../sessions.ts";
25
+ import type { Speaker, Tier } from "../speakers.ts";
31
26
  import { type ToolTiers, toolsForTier, toolTiers } from "../tool-tiers.ts";
32
- import { ConversationSessions } from "./conversation-sessions.ts";
33
- import { ASK_USER_TOOL } from "./extensions/ask-user.ts";
34
27
  import {
35
- ConfirmationGate,
36
- confirmedTurnText,
37
- } from "./extensions/confirmation-gate.ts";
38
- import { COMPACT_TOOL } from "./extensions/self-compact-guard.ts";
28
+ type AgentModelSettings,
29
+ bridgeTurnRefusal,
30
+ chosenAgentModel,
31
+ useChosenAgentModel,
32
+ } from "./bridge-guard.ts";
33
+ import { ConversationSessions } from "./conversation-sessions.ts";
34
+ import { confirmedTurnText } from "./extensions/confirmation-gate.ts";
35
+ import { loadsMemory } from "./extensions/private-memory.ts";
36
+ import { missingToolsError } from "./extensions/self-compact-guard.ts";
39
37
  import { interimPoster } from "./interim-text.ts";
38
+ import { preflightTools } from "./preflight-tools.ts";
40
39
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
41
- import {
42
- type PiAgentRuntimeOptions,
43
- TRANSCRIPT_ENTRY_CHARS,
44
- type TurnMessage,
45
- } from "./runtime-types.ts";
40
+ import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
41
+ import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
46
42
  import { archiveSessions } from "./session-archive.ts";
43
+ import {
44
+ memoryReader,
45
+ refusedSpeaker,
46
+ turnAddressee,
47
+ } from "./session-conversation.ts";
47
48
  import { SessionFactory } from "./session-factory.ts";
48
- import { type PromptImages, SteerableRun } from "./steerable-run.ts";
49
- import { lastReply, turnAnswer } from "./turn-answer.ts";
49
+ import { promptImages, SteerableRun } from "./steerable-run.ts";
50
+ import { runningCalls } from "./tool-call-scope.ts";
51
+ import {
52
+ lastReply,
53
+ transcriptOf,
54
+ turnAnswer,
55
+ unspokenTurn,
56
+ } from "./turn-answer.ts";
57
+ import { progressReporter } from "./turn-progress.ts";
50
58
  import { workerReport } from "./worker-task.ts";
51
59
 
52
60
  export {
@@ -56,6 +64,8 @@ export {
56
64
  sessionExtensions,
57
65
  } from "./runtime-types.ts";
58
66
 
67
+ const MEMORY = new Set<string>(MEMORY_TOOLS);
68
+
59
69
  /** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
60
70
  const STOPPED = "stopped by the owner";
61
71
 
@@ -69,10 +79,11 @@ export class PiAgentRuntime implements AgentRuntime {
69
79
  /** The tier and speaker of each conversation's running turn. */
70
80
  readonly #turns = new Map<
71
81
  ChannelKey,
72
- { tier: Tier; speaker: Speaker | undefined }
82
+ { tier: Tier; speaker: Speaker; withheld: boolean }
73
83
  >();
74
84
  /** Each conversation's turn in progress, which the owner may steer or stop. */
75
85
  readonly #running = new Map<ChannelKey, SteerableRun>();
86
+ readonly #memoryTurns = new Map<ChannelKey, () => void>();
76
87
 
77
88
  /** Builds the runtime without starting a session; the preflight proves it can run. */
78
89
  constructor(options: PiAgentRuntimeOptions) {
@@ -81,7 +92,8 @@ export class PiAgentRuntime implements AgentRuntime {
81
92
  this.#tiers = options.toolTiers ?? toolTiers();
82
93
  this.#factory = new SessionFactory(options, {
83
94
  speaker: (channel) => this.#turns.get(channel)?.speaker,
84
- runTask: (channel, task) => this.#runTask(channel, task),
95
+ withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
96
+ runTask: (scope, task) => this.#runTask(scope, task),
85
97
  });
86
98
  this.#sessions = new ConversationSessions(
87
99
  options,
@@ -90,54 +102,64 @@ export class PiAgentRuntime implements AgentRuntime {
90
102
  );
91
103
  }
92
104
 
93
- /**
94
- * Proves the model and every required tool resolve, in a throwaway session that runs no turn;
95
- * startup stops here before anything connects.
96
- */
105
+ /** Resolves the model and required tools in throwaway shared and private sessions, without a turn. */
97
106
  async preflight(): Promise<void> {
98
- const probe = await this.#factory.create(
99
- "probe:startup",
100
- SessionManager.inMemory(this.#factory.workDir()),
101
- new ConfirmationGate(this.#factory.link().holds, this.#options.owner),
102
- new PromptSlot(),
103
- join(this.#factory.workDir(), "probe-attachments"),
104
- undefined,
105
- "owner",
106
- );
107
- const registered = new Set(
108
- probe.session.getAllTools().map((tool) => tool.name),
107
+ const required = this.#factory.requiredTools();
108
+ const registered = await preflightTools(
109
+ this.#factory,
110
+ this.#options.owner,
111
+ required,
109
112
  );
110
- probe.session.dispose();
111
- const expected = [
112
- ...this.#factory.link().requiredTools,
113
- COMPACT_TOOL,
114
- ASK_USER_TOOL,
115
- ...planOrder(this.#factory.plan).flatMap(
116
- (tool) => tool.snapshot().requiredTools ?? [],
117
- ),
118
- ];
119
- const missing = expected.filter((name) => !registered.has(name));
120
- if (missing.length > 0) {
121
- throw new ConfigError(
122
- `required tools are not registered: ${missing.join(", ")}`,
123
- );
124
- }
113
+ const missing = required.filter((name) => !registered.has(name));
114
+ if (missing.length > 0) throw missingToolsError(missing);
125
115
  }
126
116
 
127
117
  // pi-lens-ignore: high-complexity, high-fan-out, mixed-async-styles — one turn's lifecycle end to end; batch 4 splits it with the channel router
128
118
  async runTurn(request: TurnRequest): Promise<TurnResult> {
129
119
  const { logger, turnTimeoutMs = 10 * 60_000 } = this.#options;
120
+ if (!request.speaker) return unspokenTurn();
130
121
  const key = request.agent?.session ?? request.channel;
131
- const channelSession = await this.#sessions.freshSession(key, request);
122
+ // Resolve current scope; refuse a foreign private speaker before touching its session.
123
+ const conversation = await this.#sessions.conversation(key, request);
124
+ const refused = refusedSpeaker(conversation, request.speaker);
125
+ if (refused) return { ok: false, error: new AgentRunError(refused) };
126
+ const scoped = { ...request, conversation };
127
+ const channelSession = await this.#sessions.freshSession(key, scoped);
128
+ const who = turnAddressee(
129
+ request.speaker,
130
+ channelSession,
131
+ this.#options.owner,
132
+ );
132
133
  const { session } = channelSession;
133
- const level = await this.#thinkingLevel(key, session, request);
134
+ const agentModel = chosenAgentModel(request.agent?.name, this.#options);
135
+ const bridgeRefused =
136
+ conversation.visibility === "shared"
137
+ ? await bridgeTurnRefusal(
138
+ request.agent?.name,
139
+ this.#options,
140
+ {
141
+ messages: session.messages,
142
+ sessionManager: session.sessionManager,
143
+ reader: request.speaker.principalId,
144
+ },
145
+ agentModel,
146
+ )
147
+ : undefined;
148
+ if (bridgeRefused)
149
+ return { ok: false, error: new AgentRunError(bridgeRefused) };
150
+ const level = await this.#thinkingLevel(key, session, request, agentModel);
134
151
  if (session.thinkingLevel !== level) session.setThinkingLevel(level);
135
152
  const gate = await this.#sessions.gate(key, request.agent !== undefined);
136
153
  const pending = gate.pending();
137
154
  const registered = new Set(session.getAllTools().map((tool) => tool.name));
138
- // The speaker's tier limits the tools; a turn nobody spoke in, such as the owner's own chat, is the owner's.
139
- const tier = request.speaker?.tier ?? "owner";
155
+ // The speaker's tier limits the tools.
156
+ const { tier } = request.speaker;
140
157
  // An approving turn also has the held calls' tools, which a dispatched worker may have used.
158
+ // The agent server may keep a speaker below the owner tier out of memory: no tools, no block.
159
+ const withheld =
160
+ request.agent !== undefined &&
161
+ this.#options.agents?.memory === "owners" &&
162
+ tier !== "owner";
141
163
  const wanted = toolsForTier(
142
164
  [
143
165
  ...new Set([
@@ -149,8 +171,17 @@ export class PiAgentRuntime implements AgentRuntime {
149
171
  ],
150
172
  tier,
151
173
  this.#tiers,
174
+ ).filter((name) => !withheld || !MEMORY.has(name));
175
+ const markPrivateMemory = recordMemoryTurn(
176
+ session.sessionManager,
177
+ request.speaker.principalId,
178
+ !withheld &&
179
+ channelSession.memory !== "none" &&
180
+ memoryReader(conversation, request.speaker) !== undefined &&
181
+ loadsMemory(registered),
152
182
  );
153
- this.#turns.set(key, { tier, speaker: request.speaker });
183
+ this.#memoryTurns.set(key, markPrivateMemory);
184
+ this.#turns.set(key, { tier, speaker: request.speaker, withheld });
154
185
  const missing = wanted.filter((name) => !registered.has(name));
155
186
  if (missing.length > 0) {
156
187
  logger.warn(
@@ -167,7 +198,8 @@ export class PiAgentRuntime implements AgentRuntime {
167
198
  gate.beginTurn(
168
199
  request.selection.id,
169
200
  request.confirmed === true,
170
- addressee(request.speaker, this.#options.owner),
201
+ who,
202
+ request.speaker,
171
203
  );
172
204
  if (request.confirmed && pending) {
173
205
  logger.info(
@@ -184,12 +216,18 @@ export class PiAgentRuntime implements AgentRuntime {
184
216
  const toolCalls: string[] = [];
185
217
  // Text written before the final answer is posted as the turn goes; the final reply stays the caller's.
186
218
  const interim = interimPoster(request, this.#options);
219
+ // The caller's live view: the text as it streams and each tool, never the thinking.
220
+ const progress = request.progress
221
+ ? progressReporter(request.progress)
222
+ : undefined;
187
223
  const unsubscribe = session.subscribe((event) => {
224
+ progress?.observe(event);
188
225
  if (event.type === "tool_execution_start") {
189
226
  toolCalls.push(event.toolName);
190
227
  interim?.toolStart(event.toolName);
191
228
  }
192
229
  if (event.type === "message_end") {
230
+ if (carriesMemory(event.message)) markPrivateMemory();
193
231
  turnMessages.push(event.message);
194
232
  interim?.messageEnd(event.message);
195
233
  }
@@ -197,7 +235,7 @@ export class PiAgentRuntime implements AgentRuntime {
197
235
  const slot = this.#sessions.slot(key);
198
236
  slot.bind(
199
237
  request.interactive
200
- ? this.#options.prompts?.(request.channel, request.speaker)
238
+ ? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
201
239
  : undefined,
202
240
  request.agent?.name ?? assistantName(),
203
241
  interim ? () => interim.flush() : undefined,
@@ -220,11 +258,7 @@ export class PiAgentRuntime implements AgentRuntime {
220
258
  const attachments = request.attachments ?? NO_ATTACHMENTS;
221
259
  const text =
222
260
  request.confirmed && pending
223
- ? confirmedTurnText(
224
- pending,
225
- request.text,
226
- addressee(request.speaker, this.#options.owner),
227
- )
261
+ ? confirmedTurnText(pending, request.text, who)
228
262
  : request.text;
229
263
  await running.run(() =>
230
264
  session.prompt(
@@ -244,9 +278,11 @@ export class PiAgentRuntime implements AgentRuntime {
244
278
  } finally {
245
279
  this.#running.delete(key);
246
280
  this.#turns.delete(key);
281
+ this.#memoryTurns.delete(key);
247
282
  cancelTimeout();
248
283
  slot.unbind();
249
284
  unsubscribe();
285
+ progress?.close();
250
286
  await interim?.flush();
251
287
  gate.endTurn();
252
288
  const usage = session.getContextUsage();
@@ -324,23 +360,33 @@ export class PiAgentRuntime implements AgentRuntime {
324
360
  return stopped;
325
361
  }
326
362
 
327
- /**
328
- * Runs a task in a fresh, unsaved session with the selected tools and the channel's
329
- * confirmation gate, and returns its final text.
330
- */
363
+ /** Runs a task in an unsaved session with the selected tools and conversation's gate. */
331
364
  // pi-lens-ignore: high-fan-out — builds, runs, and disposes one transient session; batch 4 turns it into the core's child-run API
332
- async #runTask(channel: ChannelKey, task: TransientTask): Promise<string> {
365
+ async #runTask(
366
+ scope: { turn: ChannelKey; home: ChannelKey },
367
+ task: TransientTask,
368
+ ): Promise<string> {
333
369
  const { dataDir } = this.#options;
334
370
  const { signal } = task;
335
- // A worker asks nothing: its held actions wait for the owner's next message.
371
+ // A task works at the tier of the turn that started it; without one there is no tier to take.
372
+ const turn = this.#turns.get(scope.turn);
373
+ if (!turn)
374
+ throw new AgentRunError(
375
+ `a task runs beside a turn of its conversation, at that turn's tier, and ${scope.turn} has no turn running`,
376
+ );
377
+ // A worker inherits the starting turn's conversation and memory policy, without asking.
378
+ const parent = await this.#sessions.session(scope.turn);
379
+ const { conversation, memory } = parent;
336
380
  const worker = await this.#factory.create(
337
- channel,
381
+ scope.home,
338
382
  SessionManager.inMemory(this.#factory.workDir()),
339
- await this.#sessions.gate(channel, false),
383
+ await this.#sessions.gate(scope.home, false),
340
384
  new PromptSlot(),
341
- ownerAttachmentDir(dataDir, channel),
385
+ ownerAttachmentDir(dataDir, scope.home),
342
386
  undefined,
343
387
  "owner",
388
+ conversation,
389
+ { turn: scope.turn, memory },
344
390
  );
345
391
  const { session } = worker;
346
392
  const toolCalls: string[] = [];
@@ -352,16 +398,17 @@ export class PiAgentRuntime implements AgentRuntime {
352
398
  const timer = setTimeout(abort, task.timeoutMs);
353
399
  try {
354
400
  const registered = new Set(session.getAllTools().map((t) => t.name));
355
- const excluded = new Set([...task.exclude, ASK_USER_TOOL]);
356
- // A task works at the tier of the turn that started it.
357
- const tier = this.#turns.get(channel)?.tier ?? "owner";
401
+ // A worker that loaded the reader's memory may report it: the call that started it, and the
402
+ // calls that one runs within, then record their results as the reader's.
403
+ if (memoryReader(conversation, turn.speaker) && loadsMemory(registered)) {
404
+ parent.draws.drawn(runningCalls());
405
+ this.#memoryTurns.get(scope.turn)?.();
406
+ }
407
+ const { tier } = turn;
358
408
  worker.tools = this.#factory
359
- .toolsFor(task.selection)
409
+ .taskTools(task.selection, task.exclude)
360
410
  .filter(
361
- (name) =>
362
- !excluded.has(name) &&
363
- registered.has(name) &&
364
- this.#tiers.allows(tier, name),
411
+ (name) => registered.has(name) && this.#tiers.allows(tier, name),
365
412
  );
366
413
  session.setActiveToolsByName([...worker.tools]);
367
414
  await withoutReplyFiles(() => session.prompt(task.text));
@@ -409,39 +456,31 @@ export class PiAgentRuntime implements AgentRuntime {
409
456
  async heldActions(
410
457
  session: ChannelKey,
411
458
  ): Promise<PendingConfirmation | undefined> {
412
- const gate = await this.#sessions.gate(session, true);
413
- return gate.pending();
459
+ return this.#sessions.held(session);
414
460
  }
415
461
 
416
462
  async recentTranscript(
417
463
  channel: ChannelKey,
418
464
  limit: number,
419
465
  ): Promise<TranscriptEntry[]> {
420
- const { session } = await this.#sessions.session(channel);
421
- const entries: TranscriptEntry[] = [];
422
- for (const message of session.messages) {
423
- if (message.role !== "user" && message.role !== "assistant") continue;
424
- const text = textOf(message.content).trim();
425
- if (text)
426
- entries.push({
427
- role: message.role,
428
- text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
429
- });
430
- }
431
- return entries.slice(-limit);
466
+ await this.#sessions.held(channel);
467
+ return transcriptOf(await this.#sessions.messages(channel), limit);
432
468
  }
433
469
 
434
- /**
435
- * The turn's thinking level: an agent's fixed setting, or the judge's pick, which is kept for the
436
- * next turn while the judge is unsure. An agent session also switches to the agent's model here.
437
- */
470
+ /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
438
471
  async #thinkingLevel(
439
472
  key: ChannelKey,
440
473
  session: AgentSession,
441
474
  request: TurnRequest,
475
+ agentModel?: AgentModelSettings,
442
476
  ): Promise<ThinkingLevel> {
443
477
  const setting = request.agent
444
- ? await this.#useAgentModel(session, request.agent.name)
478
+ ? await useChosenAgentModel(
479
+ session,
480
+ this.#modelRuntime,
481
+ request.agent.name,
482
+ agentModel,
483
+ )
445
484
  : AUTO_THINKING;
446
485
  if (setting !== AUTO_THINKING) {
447
486
  this.#sessions.judged.delete(key);
@@ -455,38 +494,7 @@ export class PiAgentRuntime implements AgentRuntime {
455
494
  return level;
456
495
  }
457
496
 
458
- /** Switches an agent session to the agent's current model; returns its thinking setting. */
459
- async #useAgentModel(
460
- session: AgentSession,
461
- name: string,
462
- ): Promise<ThinkingSetting> {
463
- const agents = this.#options.agents;
464
- if (!agents)
465
- throw new ConfigError("agent turns need the runtime's agents option");
466
- const { model, thinking } = agents.modelOf(name);
467
- const ref = parseModelRef(model);
468
- if (
469
- session.model?.provider !== ref?.provider ||
470
- session.model?.id !== ref?.id
471
- ) {
472
- const resolved = ref && this.#modelRuntime.getModel(ref.provider, ref.id);
473
- if (!resolved)
474
- throw new AgentRunError(
475
- `${name}'s model ${model} is not available on this host`,
476
- );
477
- await session.setModel(resolved);
478
- }
479
- return thinking;
480
- }
481
-
482
497
  dispose(): void {
483
498
  this.#sessions.dispose();
484
499
  }
485
500
  }
486
-
487
- function promptImages(attachments: TurnAttachments): PromptImages {
488
- return attachments.images.map((image) => ({
489
- type: "image" as const,
490
- ...image,
491
- }));
492
- }
@@ -0,0 +1,47 @@
1
+ import { join } from "node:path";
2
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
3
+ import { ConfirmationGate } from "./extensions/confirmation-gate.ts";
4
+ import { PromptSlot } from "./prompt-slot.ts";
5
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
6
+ import type { SessionFactory } from "./session-factory.ts";
7
+
8
+ /**
9
+ * Tools available in either startup scope, without running a turn or retaining a session.
10
+ * The private probe is built only when the shared one lacks a required tool, so a host
11
+ * without scoped tools starts exactly as before.
12
+ */
13
+ export async function preflightTools(
14
+ factory: SessionFactory,
15
+ owner: PiAgentRuntimeOptions["owner"],
16
+ required: readonly string[],
17
+ ): Promise<Set<string>> {
18
+ const registered = new Set<string>();
19
+ for (const conversation of [
20
+ { visibility: "shared" },
21
+ { visibility: "private", principalId: owner.id },
22
+ ] as const) {
23
+ if (
24
+ conversation.visibility === "private" &&
25
+ required.every((name) => registered.has(name))
26
+ )
27
+ break;
28
+ const probe = await factory.create(
29
+ conversation.visibility === "shared"
30
+ ? "probe:startup"
31
+ : "probe:startup-private",
32
+ SessionManager.inMemory(factory.workDir()),
33
+ new ConfirmationGate(factory.link().holds, owner),
34
+ new PromptSlot(),
35
+ join(factory.workDir(), "probe-attachments"),
36
+ undefined,
37
+ "owner",
38
+ conversation,
39
+ );
40
+ try {
41
+ for (const tool of probe.session.getAllTools()) registered.add(tool.name);
42
+ } finally {
43
+ probe.session.dispose();
44
+ }
45
+ }
46
+ return registered;
47
+ }
@@ -1,5 +1,5 @@
1
- import type { OwnerPrompts, OwnerQuestion } from "../domain/owner-prompts.ts";
2
1
  import { assistantName } from "../i18n/index.ts";
2
+ import type { OwnerQuestion, Prompts } from "../interactions/prompts.ts";
3
3
 
4
4
  /**
5
5
  * A run's way to ask the owner while it works. A turn they started or a report turn binds the
@@ -9,7 +9,7 @@ import { assistantName } from "../i18n/index.ts";
9
9
  * leaves out.
10
10
  */
11
11
  export class PromptSlot {
12
- #prompts: OwnerPrompts | undefined;
12
+ #prompts: Prompts | undefined;
13
13
  #beforeCard: (() => Promise<void>) | undefined;
14
14
  #asker = assistantName();
15
15
  #open = 0;
@@ -21,7 +21,7 @@ export class PromptSlot {
21
21
  * runs before each card opens, such as posting the text the turn wrote before it.
22
22
  */
23
23
  bind(
24
- prompts: OwnerPrompts | undefined,
24
+ prompts: Prompts | undefined,
25
25
  asker: string,
26
26
  beforeCard?: () => Promise<void>,
27
27
  ): void {
@@ -42,12 +42,12 @@ export class PromptSlot {
42
42
  }
43
43
 
44
44
  /** The turn's prompts, each open card counted as waiting; undefined when none is bound. */
45
- get prompts(): OwnerPrompts | undefined {
45
+ get prompts(): Prompts | undefined {
46
46
  const prompts = this.#prompts;
47
47
  if (!prompts) return undefined;
48
48
  return {
49
- confirm: (title, message, signal) =>
50
- this.#waiting(() => prompts.confirm(title, message, signal)),
49
+ confirm: (title, message, signal, minTier) =>
50
+ this.#waiting(() => prompts.confirm(title, message, signal, minTier)),
51
51
  ask: (title, question: OwnerQuestion, signal) =>
52
52
  this.#waiting(() => prompts.ask(title, question, signal)),
53
53
  };
@@ -0,0 +1,129 @@
1
+ import type {
2
+ ContextWithSystemEvent,
3
+ SessionManager,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
6
+ import { MEMORY_TOOLS } from "../modules/memory/owner-memory.ts";
7
+ import { guardHidesPrivateExchange } from "./extensions/private-memory.ts";
8
+ import { SCOPE_ENTRY } from "./session-scope.ts";
9
+
10
+ /** A turn's reader and whether its request carried private memory, retained across compaction. */
11
+ export const MEMORY_TURN_ENTRY = "roundtable-memory-turn";
12
+ interface ReaderRecord {
13
+ reader: string;
14
+ privateMemory: boolean;
15
+ }
16
+ type Messages = ContextWithSystemEvent["messages"];
17
+ const MEMORY = new Set<string>(MEMORY_TOOLS);
18
+
19
+ /** Persist before prompting; the returned marker records later memory exchanges or memory-loaded workers. */
20
+ export function recordMemoryTurn(
21
+ manager: Pick<SessionManager, "appendCustomEntry">,
22
+ reader: string,
23
+ privateMemory: boolean,
24
+ ): () => void {
25
+ const turnId = crypto.randomUUID();
26
+ manager.appendCustomEntry(MEMORY_TURN_ENTRY, {
27
+ turnId,
28
+ reader,
29
+ privateMemory,
30
+ });
31
+ return () => {
32
+ if (privateMemory) return;
33
+ privateMemory = true;
34
+ manager.appendCustomEntry(MEMORY_TURN_ENTRY, {
35
+ turnId,
36
+ reader,
37
+ privateMemory,
38
+ });
39
+ };
40
+ }
41
+
42
+ /** Includes immediate validation/truncation failures and calls an aborted turn left unanswered. */
43
+ export function carriesMemory(
44
+ message: ContextWithSystemEvent["messages"][number],
45
+ ): boolean {
46
+ if (message.role === "assistant")
47
+ return message.content.some(
48
+ (part) => part.type === "toolCall" && MEMORY.has(part.name),
49
+ );
50
+ if (message.role !== "toolResult") return false;
51
+ return (
52
+ MEMORY.has(message.toolName) ||
53
+ (typeof message.details === "object" &&
54
+ message.details !== null &&
55
+ "privateTo" in message.details)
56
+ );
57
+ }
58
+
59
+ function readerRecord(data: unknown): ReaderRecord | undefined {
60
+ if (typeof data !== "object" || data === null) return undefined;
61
+ if (
62
+ !("reader" in data) ||
63
+ !("privateMemory" in data) ||
64
+ typeof data.reader !== "string" ||
65
+ data.reader.length === 0 ||
66
+ typeof data.privateMemory !== "boolean"
67
+ )
68
+ return undefined;
69
+ return { reader: data.reader, privateMemory: data.privateMemory };
70
+ }
71
+
72
+ /**
73
+ * Whether bridge's raw branch would replay another reader's private turn, including retained
74
+ * prompts/reasoning and compacted entries. Explicit privateTo results still restrict the reader,
75
+ * and SYSTEM is a reader of its own. Untagged memory before the first scope record is no one's
76
+ * in this public form; later unrecorded exchanges fail closed.
77
+ */
78
+ export function bridgeHistoryHidesMemory(
79
+ branch: ReturnType<SessionManager["getBranch"]>,
80
+ reader: string | undefined,
81
+ ): boolean {
82
+ return bridgeHistoryHidesMemoryFor(branch, reader);
83
+ }
84
+
85
+ /**
86
+ * The core's own check, which public `bridgeHistoryHidesMemory` leaves its attribution knob out
87
+ * of: untagged memory before the first scope record keeps the primary-owner legacy attribution,
88
+ * and SYSTEM counts as primary owner when `primaryOwner` is supplied.
89
+ */
90
+ export function bridgeHistoryHidesMemoryFor(
91
+ branch: ReturnType<SessionManager["getBranch"]>,
92
+ reader: string | undefined,
93
+ primaryOwner?: string,
94
+ ): boolean {
95
+ const normalized = (who: string | undefined) =>
96
+ who === SYSTEM_PRINCIPAL && primaryOwner !== undefined ? primaryOwner : who;
97
+ const current = normalized(reader);
98
+ let record: ReaderRecord | undefined;
99
+ let messages: Messages = [];
100
+ let legacy = true;
101
+ const hidden = () => {
102
+ const whose = normalized(record?.reader);
103
+ const carried = record?.privateMemory || messages.some(carriesMemory);
104
+ if (record && carried && whose !== current) return true;
105
+ return guardHidesPrivateExchange(messages, {
106
+ shared: true,
107
+ reader: current,
108
+ legacyOwner: record ? whose : legacy ? primaryOwner : undefined,
109
+ turnReader: whose,
110
+ });
111
+ };
112
+ for (const entry of branch) {
113
+ if (
114
+ entry.type === "custom" &&
115
+ (entry.customType === MEMORY_TURN_ENTRY ||
116
+ entry.customType === SCOPE_ENTRY)
117
+ ) {
118
+ if (hidden()) return true;
119
+ messages = [];
120
+ if (entry.customType === SCOPE_ENTRY) legacy = false;
121
+ else {
122
+ record = readerRecord(entry.data);
123
+ // A corrupt record cannot establish safe ownership of a private turn.
124
+ if (!record) return true;
125
+ }
126
+ } else if (entry.type === "message") messages.push(entry.message);
127
+ }
128
+ return hidden();
129
+ }