pi-roundtable 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +618 -86
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +150 -86
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +87 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +82 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +42 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/drain.ts +56 -5
  69. package/src/core/errors.ts +13 -0
  70. package/src/core/host-background.ts +56 -0
  71. package/src/core/host.ts +52 -68
  72. package/src/core/i18n/cards.ts +8 -4
  73. package/src/core/i18n/conversation.ts +2 -0
  74. package/src/core/identity/access-policy.ts +227 -0
  75. package/src/core/identity/actor-facts.ts +65 -0
  76. package/src/core/identity/caching-principal-store.ts +111 -0
  77. package/src/core/identity/config-owner.ts +145 -0
  78. package/src/core/identity/contact.ts +27 -0
  79. package/src/core/identity/identity-plugin.ts +56 -0
  80. package/src/core/identity/identity-schema.ts +282 -0
  81. package/src/core/identity/identity-service.ts +489 -0
  82. package/src/core/identity/identity-view.ts +77 -0
  83. package/src/core/identity/plugin-identities.ts +109 -0
  84. package/src/core/identity/principal-reads.ts +38 -0
  85. package/src/core/identity/principal-store.ts +478 -0
  86. package/src/core/identity/principal-tables.ts +53 -0
  87. package/src/core/identity/seen-throttle.ts +57 -0
  88. package/src/core/identity/ulid.ts +36 -0
  89. package/src/core/identity.ts +34 -5
  90. package/src/core/interactions/prompts.ts +127 -0
  91. package/src/core/modules/background/background-turns.ts +56 -17
  92. package/src/core/modules/background/personal-target.ts +50 -0
  93. package/src/core/modules/delegation/delegate.ts +28 -14
  94. package/src/core/modules/delegation/delegator.ts +23 -7
  95. package/src/core/modules/memory/owner-memory.ts +123 -50
  96. package/src/core/modules/notify/notify.ts +53 -13
  97. package/src/core/modules/notify/session-notify.ts +110 -0
  98. package/src/core/modules/schedules/prechecks.ts +8 -2
  99. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  100. package/src/core/modules/schedules/schedule-store.ts +51 -20
  101. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  102. package/src/core/modules/schedules/scheduler.ts +56 -4
  103. package/src/core/modules/schedules/schedules.ts +43 -14
  104. package/src/core/plugin.ts +48 -0
  105. package/src/core/presence/direct-channels.ts +104 -0
  106. package/src/core/registry/contributions.ts +63 -3
  107. package/src/core/routing/channel-queue.ts +18 -1
  108. package/src/core/routing/channel-router.ts +274 -21
  109. package/src/core/routing/conversation-turns.ts +45 -10
  110. package/src/core/routing/surface-port.ts +32 -2
  111. package/src/core/routing/system-turns.ts +29 -0
  112. package/src/core/runtime/abort-guard.ts +58 -0
  113. package/src/core/runtime/bridge-guard.ts +225 -0
  114. package/src/core/runtime/compaction-tiers.ts +5 -1
  115. package/src/core/runtime/conversation-sessions.ts +139 -12
  116. package/src/core/runtime/extensions/ask-user.ts +1 -1
  117. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  118. package/src/core/runtime/extensions/private-memory.ts +447 -0
  119. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  120. package/src/core/runtime/pi-agent-runtime.ts +174 -190
  121. package/src/core/runtime/preflight-tools.ts +47 -0
  122. package/src/core/runtime/prompt-slot.ts +4 -4
  123. package/src/core/runtime/reader-history.ts +129 -0
  124. package/src/core/runtime/runtime-plugin.ts +51 -5
  125. package/src/core/runtime/runtime-types.ts +48 -12
  126. package/src/core/runtime/session-conversation.ts +143 -0
  127. package/src/core/runtime/session-factory.ts +115 -15
  128. package/src/core/runtime/session-scope.ts +78 -0
  129. package/src/core/runtime/steerable-run.ts +21 -3
  130. package/src/core/runtime/thinking-level.ts +47 -0
  131. package/src/core/runtime/tool-call-scope.ts +43 -0
  132. package/src/core/runtime/turn-answer.ts +30 -1
  133. package/src/core/runtime/turn-wrapup.ts +63 -0
  134. package/src/core/services.ts +27 -1
  135. package/src/core/sessions.ts +30 -0
  136. package/src/core/speakers.ts +21 -19
  137. package/src/core/testing/background.ts +31 -0
  138. package/src/core/testing/fixture-database.ts +55 -0
  139. package/src/core/testing/map-identity.ts +76 -0
  140. package/src/core/testing/module-sessions.ts +134 -0
  141. package/src/core/testing/modules.ts +108 -21
  142. package/src/core/testing/owner.ts +17 -0
  143. package/src/core/testing/surface-contract.ts +118 -41
  144. package/src/core/testing/test-host.ts +79 -16
  145. package/src/core/testing/tool-set.ts +32 -0
  146. package/src/core/tool-set.snapshot.json +4 -4
  147. package/src/core/tool-tiers.ts +57 -3
  148. package/src/discord/index.ts +8 -0
  149. package/src/index.ts +48 -8
  150. package/src/kit/compaction.ts +13 -0
  151. package/src/kit/index.ts +10 -0
  152. package/src/kit/support.ts +1 -1
  153. package/src/testing.ts +55 -16
  154. package/templates/.env.example +1 -1
  155. package/templates/adapters/web/README.md +7 -0
  156. package/templates/adapters/web/roundtable.config.ts +14 -10
  157. package/templates/roundtable.config.ts +12 -1
  158. package/src/core/domain/owner-prompts.ts +0 -45
@@ -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
+ }
@@ -7,12 +7,24 @@ import type {
7
7
  } 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 { IdentityService } from "../identity/identity-service.ts";
10
11
  import type { OwnerIdentity } from "../identity.ts";
12
+ import type { PromptScope } from "../interactions/prompts.ts";
11
13
  import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
12
- import type { ModelRef, ThinkingLevel } from "../models.ts";
14
+ import {
15
+ formatModelRef,
16
+ type ModelRef,
17
+ type ThinkingLevel,
18
+ } from "../models.ts";
13
19
  import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
14
- import { RUNTIME } from "../services.ts";
20
+ import { CONVERSATIONS, IDENTITY, RUNTIME } from "../services.ts";
15
21
  import type { Speaker } from "../speakers.ts";
22
+ import {
23
+ BRIDGE_MEMORY_FIX,
24
+ bridgeMemoryProblem,
25
+ liveBridgeRefusal,
26
+ onClaudeBridge,
27
+ } from "./bridge-guard.ts";
16
28
  import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
17
29
  import { PiAgentRuntime } from "./pi-agent-runtime.ts";
18
30
 
@@ -63,6 +75,10 @@ export interface RuntimePluginOptions {
63
75
  interimText?: InterimTextMode;
64
76
  /** An intermediate text this long or longer is posted as an ordinary message; default 400. */
65
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;
66
82
  }
67
83
 
68
84
  /**
@@ -79,18 +95,36 @@ export function runtimePlugin(
79
95
  PendingConfirmationStore.attach(context.database()),
80
96
  ): RoundtablePlugin {
81
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
+ });
82
107
  return {
83
108
  name: RUNTIME_PLUGIN,
84
109
  migrations: PendingConfirmationStore.migrations(),
85
110
  provides: [RUNTIME],
86
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
+ }
87
119
  await runtime?.preflight?.();
88
120
  },
89
121
  setup: async (context) => {
90
- const { logger, providers } = context;
122
+ const { logger, providers, services } = context;
123
+ identity = services.find(IDENTITY);
124
+ warn = (message) => logger.warn({}, message);
91
125
  const heldActions = await openHeldActions(context);
92
- const prompts = (channel: ChannelKey, speaker?: Speaker) =>
93
- context.surfaces.prompts(channel, speaker);
126
+ const prompts = (channel: ChannelKey, scope?: PromptScope | Speaker) =>
127
+ context.surfaces.prompts(channel, scope);
94
128
  const agents = () => options.agents?.current();
95
129
  const running: AgentRuntime = providers.filled.has("runtime")
96
130
  ? providers.runtime(
@@ -108,6 +142,17 @@ export function runtimePlugin(
108
142
  )
109
143
  : new PiAgentRuntime({
110
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,
111
156
  sessions: context.sessions,
112
157
  agentDir: options.agentDir,
113
158
  modelRuntime: options.modelRuntime,
@@ -123,6 +168,7 @@ export function runtimePlugin(
123
168
  }),
124
169
  confirmations: heldActions,
125
170
  toolTiers: context.toolTiers,
171
+ stopping: () => context.queue.closed,
126
172
  get agents() {
127
173
  return agents();
128
174
  },
@@ -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,17 +71,23 @@ 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. */
69
82
  turnTimeoutMs?: number;
83
+ /**
84
+ * How long a turn that was aborted, by its timeout or a stop, gets to end before its session is
85
+ * disposed of and the turn is reported failed; default 15 seconds. A model that ignores the
86
+ * abort would otherwise hold the turn, and the shutdown drain, for ever.
87
+ */
88
+ turnAbortGraceMs?: number;
89
+ /** Whether the host is shutting down: a turn or a task is then refused before it starts. */
90
+ stopping?: () => boolean;
70
91
  /**
71
92
  * Whether a turn given a place for interim posts shows the text it writes before its final
72
93
  * answer as it goes; default "on". "off" posts only the final reply.
@@ -76,6 +97,8 @@ export interface PiAgentRuntimeOptions {
76
97
  interimPrimaryChars?: number;
77
98
  /** How long a new session waits for its MCP tools to register. */
78
99
  mcpConnectTimeoutMs?: number;
100
+ /** Defaults to true: bridge shared turns refuse raw private exchanges inaccessible to the reader. */
101
+ memory?: boolean;
79
102
  }
80
103
 
81
104
  export type { AgentSessions, LoadedSkill };
@@ -91,12 +114,14 @@ export interface CoreExtensions {
91
114
  confirmationGate: ExtensionFactory;
92
115
  askUser: ExtensionFactory;
93
116
  selfCompactGuard: ExtensionFactory;
117
+ privateMemory: ExtensionFactory;
94
118
  activeTools: ExtensionFactory;
95
119
  }
96
120
 
97
121
  /**
98
122
  * 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
123
+ * and compact guard, the compactor, the MCP phase, the memory projection, so each request is
124
+ * projected after every other extension changed it, and active-tools last, so its
100
125
  * before_agent_start handler runs after every other extension's.
101
126
  */
102
127
  export function sessionExtensions(
@@ -107,7 +132,9 @@ export function sessionExtensions(
107
132
  const contributed = (tools: readonly SessionTool[]) =>
108
133
  tools.flatMap((tool) => {
109
134
  const factory = tool.snapshot().factory(session);
110
- return factory ? [{ name: tool.name, factory }] : [];
135
+ return factory
136
+ ? [{ name: tool.name, factory: scopedCalls(factory) }]
137
+ : [];
111
138
  });
112
139
  return [
113
140
  ...contributed(plan.tools),
@@ -117,6 +144,7 @@ export function sessionExtensions(
117
144
  { name: "self-compact-guard", factory: core.selfCompactGuard },
118
145
  ...contributed(plan.compaction ? [plan.compaction] : []),
119
146
  ...contributed(plan.mcp),
147
+ { name: "private-memory", factory: core.privateMemory },
120
148
  { name: "active-tools", factory: core.activeTools },
121
149
  ];
122
150
  }
@@ -129,4 +157,12 @@ export interface ChannelSession {
129
157
  revisions: string;
130
158
  /** The skills an agent session was built with, as skillsKey. */
131
159
  skills: string;
160
+ /** Whom the session's conversation serves, fixed when it was built. */
161
+ conversation: SessionConversation;
162
+ /** Whom its tool descriptions address. */
163
+ addressee: OwnerIdentity;
164
+ /** Whose memory its turns read, as resolved when it was built; a worker beside it keeps the same. */
165
+ memory: SessionContext["memory"];
166
+ /** Its tool calls running now, which a task that reads the reader's memory marks as drawing on it. */
167
+ draws: MemoryDraws;
132
168
  }
@@ -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
+ }