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
@@ -0,0 +1,205 @@
1
+ import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
2
+ import type {
3
+ AgentRuntime,
4
+ AgentSessions,
5
+ HeldActionStore,
6
+ RuntimeDeps,
7
+ } from "../contract/runtime.ts";
8
+ import type { ChannelKey } from "../domain/conversation.ts";
9
+ import type { InterimTextMode } from "../domain/interim.ts";
10
+ import type { IdentityService } from "../identity/identity-service.ts";
11
+ import type { OwnerIdentity } from "../identity.ts";
12
+ import type { PromptScope } from "../interactions/prompts.ts";
13
+ import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
14
+ import {
15
+ formatModelRef,
16
+ type ModelRef,
17
+ type ThinkingLevel,
18
+ } from "../models.ts";
19
+ import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
20
+ import { CONVERSATIONS, IDENTITY, RUNTIME } from "../services.ts";
21
+ import type { Speaker } from "../speakers.ts";
22
+ import {
23
+ BRIDGE_MEMORY_FIX,
24
+ bridgeMemoryProblem,
25
+ liveBridgeRefusal,
26
+ onClaudeBridge,
27
+ } from "./bridge-guard.ts";
28
+ import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
29
+ import { PiAgentRuntime } from "./pi-agent-runtime.ts";
30
+
31
+ /** The name of the runtime plugin, as `serviceStarted` events and the migration ledger name it. */
32
+ export const RUNTIME_PLUGIN = "runtime";
33
+
34
+ /**
35
+ * Where the agent server hands the runtime its per-agent settings. The runtime is built before the
36
+ * agent server sets up, so it reads them at each turn; on a host without the agent server they stay
37
+ * unbound and no agent turn can run.
38
+ */
39
+ export interface AgentSessionsSlot {
40
+ /** The agent server's settings, or undefined while none are bound. */
41
+ current(): AgentSessions | undefined;
42
+ /** Called once, by the agent server's setup. */
43
+ bind(sessions: AgentSessions): void;
44
+ }
45
+
46
+ export function agentSessionsSlot(): AgentSessionsSlot {
47
+ let bound: AgentSessions | undefined;
48
+ return {
49
+ current: () => bound,
50
+ bind: (sessions) => {
51
+ if (bound)
52
+ throw new Error(
53
+ "the agent sessions are bound once, by the agent server",
54
+ );
55
+ bound = sessions;
56
+ },
57
+ };
58
+ }
59
+
60
+ export interface RuntimePluginOptions {
61
+ /** Who the conversations serve, as prompts and tool results name them. */
62
+ owner: OwnerIdentity & { id: string };
63
+ /** Shared with the rest of the host, so logins refresh in one place. */
64
+ modelRuntime: ModelRuntime;
65
+ agentDir: string;
66
+ dataDir: string;
67
+ /** The assistant's model, and the thinking level the effort judge falls back to. */
68
+ model: ModelRef;
69
+ thinking: ThinkingLevel;
70
+ /** How sure the effort judge must be before its answer is used. */
71
+ judgeThreshold: number;
72
+ /** The agent server's settings, bound when it sets up; without a slot no agent turn runs. */
73
+ agents?: AgentSessionsSlot;
74
+ /** Whether turns post the text they write before their final answer as they go; default "on". */
75
+ interimText?: InterimTextMode;
76
+ /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
77
+ interimPrimaryChars?: number;
78
+ /** Whether the host keeps each person's memory; bridge turns then check private history. */
79
+ memory?: boolean;
80
+ /** Why the configuration lets several people speak in a shared conversation, as `configuredCrowd` says. */
81
+ crowd?: string;
82
+ }
83
+
84
+ /**
85
+ * The runtime every conversation turn runs on, built once at setup and provided as `RUNTIME`: the
86
+ * `runtime` provider slot's when a plugin fills it, Pi's otherwise. It keeps the held actions'
87
+ * table, so a host without the agent server still holds and restores actions across a restart.
88
+ */
89
+ export function runtimePlugin(
90
+ options: RuntimePluginOptions,
91
+ /** Opens the held actions' store; a test hands a stand-in, so the plugin sets up without a database. */
92
+ openHeldActions: (
93
+ context: Pick<PluginContext, "database">,
94
+ ) => Promise<HeldActionStore> = (context) =>
95
+ PendingConfirmationStore.attach(context.database()),
96
+ ): RoundtablePlugin {
97
+ let runtime: AgentRuntime | undefined;
98
+ let identity: IdentityService | undefined;
99
+ let warn: ((message: string) => void) | undefined;
100
+ // Admission is a startup risk warning, not proof that this conversation holds private history.
101
+ const bridgeRefusal = liveBridgeRefusal({
102
+ memory: options.memory,
103
+ crowd: options.crowd,
104
+ identity: () => identity,
105
+ primary: options.owner.id,
106
+ });
107
+ return {
108
+ name: RUNTIME_PLUGIN,
109
+ migrations: PendingConfirmationStore.migrations(),
110
+ provides: [RUNTIME],
111
+ preflight: async () => {
112
+ if (onClaudeBridge(options.model)) {
113
+ const why = await bridgeRefusal();
114
+ if (why)
115
+ warn?.(
116
+ `${bridgeMemoryProblem(formatModelRef(options.model), why)} ${BRIDGE_MEMORY_FIX}`,
117
+ );
118
+ }
119
+ await runtime?.preflight?.();
120
+ },
121
+ setup: async (context) => {
122
+ const { logger, providers, services } = context;
123
+ identity = services.find(IDENTITY);
124
+ warn = (message) => logger.warn({}, message);
125
+ const heldActions = await openHeldActions(context);
126
+ const prompts = (channel: ChannelKey, scope?: PromptScope | Speaker) =>
127
+ context.surfaces.prompts(channel, scope);
128
+ const agents = () => options.agents?.current();
129
+ const running: AgentRuntime = providers.filled.has("runtime")
130
+ ? providers.runtime(
131
+ runtimeDeps({
132
+ logger,
133
+ env: context.env,
134
+ owner: { id: options.owner.id, name: options.owner.name },
135
+ sessions: context.sessions,
136
+ toolTiers: context.toolTiers,
137
+ prompts,
138
+ agents,
139
+ confirmations: heldActions,
140
+ judge: providers.judge,
141
+ }),
142
+ )
143
+ : new PiAgentRuntime({
144
+ owner: options.owner,
145
+ // Read at every turn that names no conversation, after every plugin set up.
146
+ conversationOf: async (key) => {
147
+ const record = await services.find(CONVERSATIONS)?.get(key);
148
+ if (!record) return undefined;
149
+ return record.visibility === "private" &&
150
+ record.principalId !== undefined
151
+ ? { visibility: "private", principalId: record.principalId }
152
+ : { visibility: "shared" };
153
+ },
154
+ principalOf: async (id) => services.find(IDENTITY)?.principal(id),
155
+ memory: options.memory === true,
156
+ sessions: context.sessions,
157
+ agentDir: options.agentDir,
158
+ modelRuntime: options.modelRuntime,
159
+ dataDir: options.dataDir,
160
+ model: options.model,
161
+ thinking: options.thinking,
162
+ effort: new EffortJudge({
163
+ judge: providers.judge,
164
+ brief: AGENT_BRIEF,
165
+ fallback: options.thinking,
166
+ threshold: options.judgeThreshold,
167
+ logger,
168
+ }),
169
+ confirmations: heldActions,
170
+ toolTiers: context.toolTiers,
171
+ get agents() {
172
+ return agents();
173
+ },
174
+ prompts,
175
+ logger,
176
+ ...(options.interimText
177
+ ? { interimText: options.interimText }
178
+ : {}),
179
+ ...(options.interimPrimaryChars
180
+ ? { interimPrimaryChars: options.interimPrimaryChars }
181
+ : {}),
182
+ });
183
+ runtime = running;
184
+ context.services.provide(RUNTIME, running);
185
+ return {
186
+ services: [{ name: "runtime", stop: () => running.dispose?.() }],
187
+ };
188
+ },
189
+ };
190
+ }
191
+
192
+ /** The deps a runtime provider gets, with `agents` read when asked, after the agent server bound it. */
193
+ function runtimeDeps(
194
+ deps: Omit<RuntimeDeps, "agents"> & {
195
+ agents: () => AgentSessions | undefined;
196
+ },
197
+ ): RuntimeDeps {
198
+ const { agents, ...rest } = deps;
199
+ return {
200
+ ...rest,
201
+ get agents() {
202
+ return agents();
203
+ },
204
+ };
205
+ }
@@ -7,20 +7,24 @@ import type {
7
7
  import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
8
8
  import type { ChannelKey } from "../domain/conversation.ts";
9
9
  import type { InterimTextMode } from "../domain/interim.ts";
10
- import type { OwnerPrompts } from "../domain/owner-prompts.ts";
10
+ import type { TurnConversation } from "../domain/ports.ts";
11
+ import type { Principal } from "../identity/principal-store.ts";
11
12
  import type { OwnerIdentity } from "../identity.ts";
13
+ import type { PromptScope, Prompts } from "../interactions/prompts.ts";
12
14
  import type { Logger } from "../log.ts";
13
15
  import type { ThinkingLevel, ThinkingPicker } from "../models.ts";
14
16
  import type { LinkedSessions } from "../plugin.ts";
15
17
  import {
16
18
  planOrder,
17
19
  type SessionContext,
20
+ type SessionConversation,
18
21
  type SessionPlan,
19
22
  type SessionTool,
20
23
  } from "../sessions.ts";
21
- import type { Speaker } from "../speakers.ts";
22
24
  import type { ToolTiers } from "../tool-tiers.ts";
25
+ import type { MemoryDraws } from "./extensions/private-memory.ts";
23
26
  import type { PendingConfirmationStore } from "./pending-confirmation-store.ts";
27
+ import { scopedCalls } from "./tool-call-scope.ts";
24
28
 
25
29
  export type TurnMessage = Extract<
26
30
  AgentSessionEvent,
@@ -33,8 +37,19 @@ export type CompactionEnd = Extract<
33
37
  export const TRANSCRIPT_ENTRY_CHARS = 1000;
34
38
 
35
39
  export interface PiAgentRuntimeOptions {
36
- /** Who the conversations serve, as prompts and tool results name them. */
37
- owner: OwnerIdentity;
40
+ /**
41
+ * The primary owner, by their principal id: their conversations' prompts and tool results name
42
+ * them as configured, and the host's own turns address them.
43
+ */
44
+ owner: OwnerIdentity & { id: string };
45
+ /**
46
+ * The host's record of a conversation, read at every turn that names none, so a record changed
47
+ * while its session is open counts, and when its transcript is read; without it, or without a
48
+ * record, the conversation is as its open session or its history was made, else shared.
49
+ */
50
+ conversationOf?: (key: ChannelKey) => Promise<TurnConversation | undefined>;
51
+ /** A principal's name and pronouns, which a private conversation's tool descriptions use. */
52
+ principalOf?: (id: string) => Promise<Principal | undefined>;
38
53
  agentDir: string;
39
54
  /** Shared by the host's sessions, so logins refresh in one place. */
40
55
  modelRuntime: ModelRuntime;
@@ -56,13 +71,11 @@ export interface PiAgentRuntimeOptions {
56
71
  /** The agent server's sessions: their shared workspace, model, and skills. */
57
72
  agents?: AgentSessions;
58
73
  /**
59
- * Cards the owner answers in a channel, for interactive turns; undefined for a channel that
60
- * cannot show them. Without it every held action waits for their next message.
74
+ * The prompts of a channel for an interactive turn's scope, made from its speaker and its
75
+ * conversation's visibility; undefined for a channel that cannot show them. Without it every
76
+ * held action waits for a message that approves it.
61
77
  */
62
- prompts?: (
63
- channel: ChannelKey,
64
- speaker?: Speaker,
65
- ) => OwnerPrompts | undefined;
78
+ prompts?: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
66
79
  /** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
67
80
  toolTiers?: ToolTiers;
68
81
  /** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
@@ -76,6 +89,8 @@ export interface PiAgentRuntimeOptions {
76
89
  interimPrimaryChars?: number;
77
90
  /** How long a new session waits for its MCP tools to register. */
78
91
  mcpConnectTimeoutMs?: number;
92
+ /** Defaults to true: bridge shared turns refuse raw private exchanges inaccessible to the reader. */
93
+ memory?: boolean;
79
94
  }
80
95
 
81
96
  export type { AgentSessions, LoadedSkill };
@@ -91,12 +106,14 @@ export interface CoreExtensions {
91
106
  confirmationGate: ExtensionFactory;
92
107
  askUser: ExtensionFactory;
93
108
  selfCompactGuard: ExtensionFactory;
109
+ privateMemory: ExtensionFactory;
94
110
  activeTools: ExtensionFactory;
95
111
  }
96
112
 
97
113
  /**
98
114
  * A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
99
- * and compact guard, the compactor, the MCP phase, and active-tools last, so its
115
+ * and compact guard, the compactor, the MCP phase, the memory projection, so each request is
116
+ * projected after every other extension changed it, and active-tools last, so its
100
117
  * before_agent_start handler runs after every other extension's.
101
118
  */
102
119
  export function sessionExtensions(
@@ -107,7 +124,9 @@ export function sessionExtensions(
107
124
  const contributed = (tools: readonly SessionTool[]) =>
108
125
  tools.flatMap((tool) => {
109
126
  const factory = tool.snapshot().factory(session);
110
- return factory ? [{ name: tool.name, factory }] : [];
127
+ return factory
128
+ ? [{ name: tool.name, factory: scopedCalls(factory) }]
129
+ : [];
111
130
  });
112
131
  return [
113
132
  ...contributed(plan.tools),
@@ -117,6 +136,7 @@ export function sessionExtensions(
117
136
  { name: "self-compact-guard", factory: core.selfCompactGuard },
118
137
  ...contributed(plan.compaction ? [plan.compaction] : []),
119
138
  ...contributed(plan.mcp),
139
+ { name: "private-memory", factory: core.privateMemory },
120
140
  { name: "active-tools", factory: core.activeTools },
121
141
  ];
122
142
  }
@@ -129,4 +149,12 @@ export interface ChannelSession {
129
149
  revisions: string;
130
150
  /** The skills an agent session was built with, as skillsKey. */
131
151
  skills: string;
152
+ /** Whom the session's conversation serves, fixed when it was built. */
153
+ conversation: SessionConversation;
154
+ /** Whom its tool descriptions address. */
155
+ addressee: OwnerIdentity;
156
+ /** Whose memory its turns read, as resolved when it was built; a worker beside it keeps the same. */
157
+ memory: SessionContext["memory"];
158
+ /** Its tool calls running now, which a task that reads the reader's memory marks as drawing on it. */
159
+ draws: MemoryDraws;
132
160
  }
@@ -0,0 +1,143 @@
1
+ import type { ChannelKey } from "../domain/conversation.ts";
2
+ import type { TurnConversation, TurnRequest } from "../domain/ports.ts";
3
+ import {
4
+ type Principal,
5
+ SYSTEM_PRINCIPAL,
6
+ } from "../identity/principal-store.ts";
7
+ import { addresseeOf, type OwnerIdentity } from "../identity.ts";
8
+ import type { SessionConversation } from "../sessions.ts";
9
+ import { addressee, type Speaker, THE_SPEAKER } from "../speakers.ts";
10
+ import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
11
+
12
+ /**
13
+ * Whom a conversation serves as something says so: an agent's is shared; another is as the turn
14
+ * says, else as the host's record says; undefined when neither does.
15
+ */
16
+ export async function namedConversation(
17
+ key: ChannelKey,
18
+ request: Pick<TurnRequest, "agent" | "conversation"> | undefined,
19
+ options: Pick<PiAgentRuntimeOptions, "conversationOf">,
20
+ ): Promise<TurnConversation | undefined> {
21
+ if (request?.agent) return { visibility: "shared" };
22
+ return request?.conversation ?? (await options.conversationOf?.(key));
23
+ }
24
+
25
+ /**
26
+ * Whom a new session's conversation serves: as the turn or the host's record names it, else
27
+ * shared, never the owner's by default. A private one's person is looked up for their name and
28
+ * pronouns.
29
+ */
30
+ export async function sessionConversation(
31
+ key: ChannelKey,
32
+ request: Pick<TurnRequest, "agent" | "conversation"> | undefined,
33
+ options: Pick<PiAgentRuntimeOptions, "conversationOf" | "principalOf">,
34
+ ): Promise<SessionConversation> {
35
+ const given = await namedConversation(key, request, options);
36
+ if (given?.visibility !== "private") return { visibility: "shared" };
37
+ const principal = await options.principalOf?.(given.principalId);
38
+ return {
39
+ visibility: "private",
40
+ principalId: given.principalId,
41
+ ...(principal ? { principal } : {}),
42
+ };
43
+ }
44
+
45
+ /** Whether the turn names its conversation otherwise than the session was made for, so it is rebuilt. */
46
+ export function conversationChanged(
47
+ session: SessionConversation,
48
+ given: TurnConversation | undefined,
49
+ ): boolean {
50
+ if (!given) return false;
51
+ if (given.visibility !== session.visibility) return true;
52
+ return (
53
+ given.visibility === "private" &&
54
+ session.visibility === "private" &&
55
+ given.principalId !== session.principalId
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Whom a session's tool descriptions address: the primary owner as configured, so their own
61
+ * conversations read as in 0.8; another private conversation's person by their name and
62
+ * pronouns; and the speaker, unnamed, in a shared one or a private one of someone the host has
63
+ * no name for.
64
+ */
65
+ export function sessionAddressee(
66
+ conversation: SessionConversation,
67
+ owner: OwnerIdentity & { id: string },
68
+ ): OwnerIdentity {
69
+ if (conversation.visibility !== "private") return THE_SPEAKER;
70
+ if (conversation.principalId === owner.id) return owner;
71
+ const named = namedPrincipal(conversation);
72
+ return named ? addresseeOf(named) : THE_SPEAKER;
73
+ }
74
+
75
+ /**
76
+ * A private conversation's person, when the host knows them by a name: one carried over from 0.8
77
+ * may be named by their id alone until someone names them, and an id is no name to address.
78
+ */
79
+ export function namedPrincipal(
80
+ conversation: SessionConversation,
81
+ ): Principal | undefined {
82
+ if (conversation.visibility !== "private") return undefined;
83
+ const { principal } = conversation;
84
+ return principal && principal.displayName !== principal.id
85
+ ? principal
86
+ : undefined;
87
+ }
88
+
89
+ /**
90
+ * Whom a turn's approvals and answers name: a private conversation's person as its session
91
+ * addresses them; the host's own turns and the primary owner as the owner; anyone else, a
92
+ * second owner too, by their name.
93
+ */
94
+ export function turnAddressee(
95
+ speaker: Speaker | undefined,
96
+ session: { conversation: SessionConversation; addressee: OwnerIdentity },
97
+ owner: OwnerIdentity & { id: string },
98
+ ): OwnerIdentity {
99
+ const { conversation } = session;
100
+ if (
101
+ speaker &&
102
+ conversation.visibility === "private" &&
103
+ speaker.principalId === conversation.principalId &&
104
+ session.addressee !== THE_SPEAKER
105
+ )
106
+ return session.addressee;
107
+ const own =
108
+ !speaker ||
109
+ speaker.principalId === owner.id ||
110
+ speaker.principalId === SYSTEM_PRINCIPAL;
111
+ return own ? owner : addressee({ ...speaker, tier: "member" }, owner);
112
+ }
113
+
114
+ /**
115
+ * Whose memory a session's running turn reads: a private conversation's person's, whoever speaks
116
+ * in it, and in a shared one the speaker's; no one's for the host's own turns there, nor while
117
+ * no turn runs.
118
+ */
119
+ export function memoryReader(
120
+ conversation: SessionConversation,
121
+ speaker: Speaker | undefined,
122
+ ): string | undefined {
123
+ if (conversation.visibility === "private") return conversation.principalId;
124
+ if (!speaker || speaker.principalId === SYSTEM_PRINCIPAL) return undefined;
125
+ return speaker.principalId;
126
+ }
127
+
128
+ /**
129
+ * Why a turn may not run in the session's conversation: someone other than its person speaking
130
+ * in a private one. The host's own turns may.
131
+ */
132
+ export function refusedSpeaker(
133
+ conversation: SessionConversation,
134
+ speaker: Speaker,
135
+ ): string | undefined {
136
+ if (conversation.visibility !== "private") return undefined;
137
+ if (
138
+ speaker.principalId === conversation.principalId ||
139
+ speaker.principalId === SYSTEM_PRINCIPAL
140
+ )
141
+ return undefined;
142
+ return `the conversation is private to ${conversation.principalId}, so a turn of ${speaker.principalId} may not run in it`;
143
+ }
@@ -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,