pi-roundtable 0.8.0 → 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 (150) hide show
  1. package/CHANGELOG.md +102 -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 +606 -78
  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 +140 -85
  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 +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -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 +41 -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/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. 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,41 +14,46 @@ 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 {
39
- COMPACT_TOOL,
40
- missingToolsError,
41
- } 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";
42
37
  import { interimPoster } from "./interim-text.ts";
38
+ import { preflightTools } from "./preflight-tools.ts";
43
39
  import { PromptSlot, workTimeout } from "./prompt-slot.ts";
44
- import {
45
- type PiAgentRuntimeOptions,
46
- TRANSCRIPT_ENTRY_CHARS,
47
- type TurnMessage,
48
- } from "./runtime-types.ts";
40
+ import { carriesMemory, recordMemoryTurn } from "./reader-history.ts";
41
+ import type { PiAgentRuntimeOptions, TurnMessage } from "./runtime-types.ts";
49
42
  import { archiveSessions } from "./session-archive.ts";
43
+ import {
44
+ memoryReader,
45
+ refusedSpeaker,
46
+ turnAddressee,
47
+ } from "./session-conversation.ts";
50
48
  import { SessionFactory } from "./session-factory.ts";
51
- import { type PromptImages, SteerableRun } from "./steerable-run.ts";
52
- 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";
53
57
  import { progressReporter } from "./turn-progress.ts";
54
58
  import { workerReport } from "./worker-task.ts";
55
59
 
@@ -60,6 +64,8 @@ export {
60
64
  sessionExtensions,
61
65
  } from "./runtime-types.ts";
62
66
 
67
+ const MEMORY = new Set<string>(MEMORY_TOOLS);
68
+
63
69
  /** AgentRuntime over the Pi SDK: one persistent session per channel, each with its own loader. */
64
70
  const STOPPED = "stopped by the owner";
65
71
 
@@ -73,10 +79,11 @@ export class PiAgentRuntime implements AgentRuntime {
73
79
  /** The tier and speaker of each conversation's running turn. */
74
80
  readonly #turns = new Map<
75
81
  ChannelKey,
76
- { tier: Tier; speaker: Speaker | undefined }
82
+ { tier: Tier; speaker: Speaker; withheld: boolean }
77
83
  >();
78
84
  /** Each conversation's turn in progress, which the owner may steer or stop. */
79
85
  readonly #running = new Map<ChannelKey, SteerableRun>();
86
+ readonly #memoryTurns = new Map<ChannelKey, () => void>();
80
87
 
81
88
  /** Builds the runtime without starting a session; the preflight proves it can run. */
82
89
  constructor(options: PiAgentRuntimeOptions) {
@@ -85,7 +92,8 @@ export class PiAgentRuntime implements AgentRuntime {
85
92
  this.#tiers = options.toolTiers ?? toolTiers();
86
93
  this.#factory = new SessionFactory(options, {
87
94
  speaker: (channel) => this.#turns.get(channel)?.speaker,
88
- runTask: (channel, task) => this.#runTask(channel, task),
95
+ withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
96
+ runTask: (scope, task) => this.#runTask(scope, task),
89
97
  });
90
98
  this.#sessions = new ConversationSessions(
91
99
  options,
@@ -94,50 +102,64 @@ export class PiAgentRuntime implements AgentRuntime {
94
102
  );
95
103
  }
96
104
 
97
- /**
98
- * Proves the model and every required tool resolve, in a throwaway session that runs no turn;
99
- * startup stops here before anything connects.
100
- */
105
+ /** Resolves the model and required tools in throwaway shared and private sessions, without a turn. */
101
106
  async preflight(): Promise<void> {
102
- const probe = await this.#factory.create(
103
- "probe:startup",
104
- SessionManager.inMemory(this.#factory.workDir()),
105
- new ConfirmationGate(this.#factory.link().holds, this.#options.owner),
106
- new PromptSlot(),
107
- join(this.#factory.workDir(), "probe-attachments"),
108
- undefined,
109
- "owner",
110
- );
111
- const registered = new Set(
112
- 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,
113
112
  );
114
- probe.session.dispose();
115
- const expected = [
116
- ...this.#factory.link().requiredTools,
117
- COMPACT_TOOL,
118
- ASK_USER_TOOL,
119
- ...planOrder(this.#factory.plan).flatMap(
120
- (tool) => tool.snapshot().requiredTools ?? [],
121
- ),
122
- ];
123
- const missing = expected.filter((name) => !registered.has(name));
113
+ const missing = required.filter((name) => !registered.has(name));
124
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,8 +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),
171
- request.speaker?.id,
201
+ who,
202
+ request.speaker,
172
203
  );
173
204
  if (request.confirmed && pending) {
174
205
  logger.info(
@@ -196,6 +227,7 @@ export class PiAgentRuntime implements AgentRuntime {
196
227
  interim?.toolStart(event.toolName);
197
228
  }
198
229
  if (event.type === "message_end") {
230
+ if (carriesMemory(event.message)) markPrivateMemory();
199
231
  turnMessages.push(event.message);
200
232
  interim?.messageEnd(event.message);
201
233
  }
@@ -203,7 +235,7 @@ export class PiAgentRuntime implements AgentRuntime {
203
235
  const slot = this.#sessions.slot(key);
204
236
  slot.bind(
205
237
  request.interactive
206
- ? this.#options.prompts?.(request.channel, request.speaker)
238
+ ? this.#options.prompts?.(request.channel, promptScopeOf(scoped))
207
239
  : undefined,
208
240
  request.agent?.name ?? assistantName(),
209
241
  interim ? () => interim.flush() : undefined,
@@ -226,11 +258,7 @@ export class PiAgentRuntime implements AgentRuntime {
226
258
  const attachments = request.attachments ?? NO_ATTACHMENTS;
227
259
  const text =
228
260
  request.confirmed && pending
229
- ? confirmedTurnText(
230
- pending,
231
- request.text,
232
- addressee(request.speaker, this.#options.owner),
233
- )
261
+ ? confirmedTurnText(pending, request.text, who)
234
262
  : request.text;
235
263
  await running.run(() =>
236
264
  session.prompt(
@@ -250,6 +278,7 @@ export class PiAgentRuntime implements AgentRuntime {
250
278
  } finally {
251
279
  this.#running.delete(key);
252
280
  this.#turns.delete(key);
281
+ this.#memoryTurns.delete(key);
253
282
  cancelTimeout();
254
283
  slot.unbind();
255
284
  unsubscribe();
@@ -331,23 +360,33 @@ export class PiAgentRuntime implements AgentRuntime {
331
360
  return stopped;
332
361
  }
333
362
 
334
- /**
335
- * Runs a task in a fresh, unsaved session with the selected tools and the channel's
336
- * confirmation gate, and returns its final text.
337
- */
363
+ /** Runs a task in an unsaved session with the selected tools and conversation's gate. */
338
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
339
- async #runTask(channel: ChannelKey, task: TransientTask): Promise<string> {
365
+ async #runTask(
366
+ scope: { turn: ChannelKey; home: ChannelKey },
367
+ task: TransientTask,
368
+ ): Promise<string> {
340
369
  const { dataDir } = this.#options;
341
370
  const { signal } = task;
342
- // 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;
343
380
  const worker = await this.#factory.create(
344
- channel,
381
+ scope.home,
345
382
  SessionManager.inMemory(this.#factory.workDir()),
346
- await this.#sessions.gate(channel, false),
383
+ await this.#sessions.gate(scope.home, false),
347
384
  new PromptSlot(),
348
- ownerAttachmentDir(dataDir, channel),
385
+ ownerAttachmentDir(dataDir, scope.home),
349
386
  undefined,
350
387
  "owner",
388
+ conversation,
389
+ { turn: scope.turn, memory },
351
390
  );
352
391
  const { session } = worker;
353
392
  const toolCalls: string[] = [];
@@ -359,16 +398,17 @@ export class PiAgentRuntime implements AgentRuntime {
359
398
  const timer = setTimeout(abort, task.timeoutMs);
360
399
  try {
361
400
  const registered = new Set(session.getAllTools().map((t) => t.name));
362
- const excluded = new Set([...task.exclude, ASK_USER_TOOL]);
363
- // A task works at the tier of the turn that started it.
364
- 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;
365
408
  worker.tools = this.#factory
366
- .toolsFor(task.selection)
409
+ .taskTools(task.selection, task.exclude)
367
410
  .filter(
368
- (name) =>
369
- !excluded.has(name) &&
370
- registered.has(name) &&
371
- this.#tiers.allows(tier, name),
411
+ (name) => registered.has(name) && this.#tiers.allows(tier, name),
372
412
  );
373
413
  session.setActiveToolsByName([...worker.tools]);
374
414
  await withoutReplyFiles(() => session.prompt(task.text));
@@ -416,39 +456,31 @@ export class PiAgentRuntime implements AgentRuntime {
416
456
  async heldActions(
417
457
  session: ChannelKey,
418
458
  ): Promise<PendingConfirmation | undefined> {
419
- const gate = await this.#sessions.gate(session, true);
420
- return gate.pending();
459
+ return this.#sessions.held(session);
421
460
  }
422
461
 
423
462
  async recentTranscript(
424
463
  channel: ChannelKey,
425
464
  limit: number,
426
465
  ): Promise<TranscriptEntry[]> {
427
- const { session } = await this.#sessions.session(channel);
428
- const entries: TranscriptEntry[] = [];
429
- for (const message of session.messages) {
430
- if (message.role !== "user" && message.role !== "assistant") continue;
431
- const text = textOf(message.content).trim();
432
- if (text)
433
- entries.push({
434
- role: message.role,
435
- text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
436
- });
437
- }
438
- return entries.slice(-limit);
466
+ await this.#sessions.held(channel);
467
+ return transcriptOf(await this.#sessions.messages(channel), limit);
439
468
  }
440
469
 
441
- /**
442
- * The turn's thinking level: an agent's fixed setting, or the judge's pick, which is kept for the
443
- * next turn while the judge is unsure. An agent session also switches to the agent's model here.
444
- */
470
+ /** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
445
471
  async #thinkingLevel(
446
472
  key: ChannelKey,
447
473
  session: AgentSession,
448
474
  request: TurnRequest,
475
+ agentModel?: AgentModelSettings,
449
476
  ): Promise<ThinkingLevel> {
450
477
  const setting = request.agent
451
- ? await this.#useAgentModel(session, request.agent.name)
478
+ ? await useChosenAgentModel(
479
+ session,
480
+ this.#modelRuntime,
481
+ request.agent.name,
482
+ agentModel,
483
+ )
452
484
  : AUTO_THINKING;
453
485
  if (setting !== AUTO_THINKING) {
454
486
  this.#sessions.judged.delete(key);
@@ -462,38 +494,7 @@ export class PiAgentRuntime implements AgentRuntime {
462
494
  return level;
463
495
  }
464
496
 
465
- /** Switches an agent session to the agent's current model; returns its thinking setting. */
466
- async #useAgentModel(
467
- session: AgentSession,
468
- name: string,
469
- ): Promise<ThinkingSetting> {
470
- const agents = this.#options.agents;
471
- if (!agents)
472
- throw new ConfigError("agent turns need the runtime's agents option");
473
- const { model, thinking } = agents.modelOf(name);
474
- const ref = parseModelRef(model);
475
- if (
476
- session.model?.provider !== ref?.provider ||
477
- session.model?.id !== ref?.id
478
- ) {
479
- const resolved = ref && this.#modelRuntime.getModel(ref.provider, ref.id);
480
- if (!resolved)
481
- throw new AgentRunError(
482
- `${name}'s model ${model} is not available on this host`,
483
- );
484
- await session.setModel(resolved);
485
- }
486
- return thinking;
487
- }
488
-
489
497
  dispose(): void {
490
498
  this.#sessions.dispose();
491
499
  }
492
500
  }
493
-
494
- function promptImages(attachments: TurnAttachments): PromptImages {
495
- return attachments.images.map((image) => ({
496
- type: "image" as const,
497
- ...image,
498
- }));
499
- }
@@ -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,7 +42,7 @@ 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 {
@@ -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
+ }