pi-roundtable 0.7.19 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +72 -15
  3. package/README.zh-TW.md +65 -13
  4. package/docs/migrating-0.8.md +132 -0
  5. package/docs/migration-0.9.md +247 -0
  6. package/docs/plugins.md +853 -81
  7. package/examples/channel-context.test.ts +88 -0
  8. package/examples/channel-context.ts +62 -0
  9. package/examples/discord-adapter.test.ts +42 -0
  10. package/examples/discord-adapter.ts +26 -0
  11. package/examples/echo-runtime.test.ts +1 -0
  12. package/examples/echo-runtime.ts +1 -1
  13. package/examples/fake-surface.test.ts +69 -6
  14. package/examples/fake-surface.ts +73 -7
  15. package/examples/headless.test.ts +72 -0
  16. package/examples/headless.ts +35 -0
  17. package/examples/http.test.ts +31 -1
  18. package/examples/http.ts +39 -0
  19. package/examples/prechecks.test.ts +1 -0
  20. package/examples/principals.test.ts +60 -0
  21. package/examples/principals.ts +48 -0
  22. package/examples/prompt.test.ts +1 -1
  23. package/examples/study-room.test.ts +29 -1
  24. package/examples/study-room.ts +16 -19
  25. package/examples/support-desk.test.ts +3 -1
  26. package/examples/tools.test.ts +6 -1
  27. package/package.json +7 -3
  28. package/src/cli/checks/database.ts +11 -0
  29. package/src/cli/checks/identity.ts +225 -0
  30. package/src/cli/checks/model.ts +21 -3
  31. package/src/cli/cli.ts +130 -5
  32. package/src/cli/config-edit.ts +83 -13
  33. package/src/cli/doctor.ts +28 -5
  34. package/src/cli/init.ts +9 -1
  35. package/src/cli/line-diff.ts +72 -0
  36. package/src/cli/principal.ts +412 -0
  37. package/src/cli/project.ts +33 -2
  38. package/src/cli/report.ts +12 -3
  39. package/src/cli/templates.ts +46 -14
  40. package/src/cli/upgrade-rewrite.ts +102 -0
  41. package/src/cli/upgrade-source.ts +430 -0
  42. package/src/cli/upgrade.ts +176 -0
  43. package/src/core/agents/agent-claim.ts +56 -34
  44. package/src/core/agents/agent-team-fixture.ts +6 -0
  45. package/src/core/agents/team-turns.ts +10 -1
  46. package/src/core/builtin/agent-server.ts +38 -101
  47. package/src/core/builtin/discord-admin.ts +4 -1
  48. package/src/core/builtin/discord.ts +71 -13
  49. package/src/core/builtin/modules.ts +197 -43
  50. package/src/core/builtin/stores.ts +40 -21
  51. package/src/core/config/access.ts +229 -0
  52. package/src/core/config/adapters.ts +120 -0
  53. package/src/core/config/config.ts +228 -94
  54. package/src/core/contract/channel-context.ts +53 -0
  55. package/src/core/contract/channels.ts +81 -9
  56. package/src/core/contract/providers.ts +3 -2
  57. package/src/core/contract/runtime.ts +31 -11
  58. package/src/core/contract/surface.ts +35 -6
  59. package/src/core/conversations/conversation-registry.ts +56 -0
  60. package/src/core/conversations/conversation-store.ts +126 -0
  61. package/src/core/conversations/conversations-plugin.ts +29 -0
  62. package/src/core/define-roundtable.ts +157 -57
  63. package/src/core/discord/agent-panel.ts +2 -2
  64. package/src/core/discord/card-audience.ts +148 -0
  65. package/src/core/discord/channel-context.ts +348 -0
  66. package/src/core/discord/connection.ts +15 -3
  67. package/src/core/discord/direct-channel.ts +54 -0
  68. package/src/core/discord/discord-adapter.ts +9 -0
  69. package/src/core/discord/discord-owners.ts +133 -0
  70. package/src/core/discord/discord-surface.ts +69 -14
  71. package/src/core/discord/fresh-marker.ts +24 -0
  72. package/src/core/discord/inbound-message.ts +21 -7
  73. package/src/core/discord/interaction-module.ts +11 -1
  74. package/src/core/discord/owner-cards-fixture.ts +80 -0
  75. package/src/core/discord/owner-cards.ts +58 -86
  76. package/src/core/discord/owner-command.ts +27 -13
  77. package/src/core/discord/stop-button.ts +4 -4
  78. package/src/core/domain/conversation.ts +10 -0
  79. package/src/core/domain/errors.ts +5 -0
  80. package/src/core/domain/ports.ts +33 -2
  81. package/src/core/domain/progress.ts +10 -0
  82. package/src/core/events.ts +1 -0
  83. package/src/core/host.ts +32 -10
  84. package/src/core/http/listeners.ts +174 -21
  85. package/src/core/http/websocket.ts +316 -0
  86. package/src/core/i18n/cards.ts +11 -9
  87. package/src/core/identity/access-policy.ts +227 -0
  88. package/src/core/identity/actor-facts.ts +65 -0
  89. package/src/core/identity/caching-principal-store.ts +111 -0
  90. package/src/core/identity/config-owner.ts +145 -0
  91. package/src/core/identity/contact.ts +27 -0
  92. package/src/core/identity/identity-plugin.ts +56 -0
  93. package/src/core/identity/identity-schema.ts +282 -0
  94. package/src/core/identity/identity-service.ts +489 -0
  95. package/src/core/identity/identity-view.ts +77 -0
  96. package/src/core/identity/plugin-identities.ts +109 -0
  97. package/src/core/identity/principal-reads.ts +38 -0
  98. package/src/core/identity/principal-store.ts +478 -0
  99. package/src/core/identity/principal-tables.ts +53 -0
  100. package/src/core/identity/seen-throttle.ts +57 -0
  101. package/src/core/identity/ulid.ts +36 -0
  102. package/src/core/identity.ts +34 -5
  103. package/src/core/interactions/prompts.ts +127 -0
  104. package/src/core/modules/background/background-turns.ts +56 -17
  105. package/src/core/modules/background/personal-target.ts +50 -0
  106. package/src/core/modules/delegation/delegate.ts +28 -14
  107. package/src/core/modules/delegation/delegator.ts +23 -7
  108. package/src/core/modules/memory/owner-memory.ts +123 -50
  109. package/src/core/modules/notify/notify.ts +53 -13
  110. package/src/core/modules/notify/session-notify.ts +110 -0
  111. package/src/core/modules/schedules/prechecks.ts +8 -2
  112. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  113. package/src/core/modules/schedules/schedule-store.ts +51 -20
  114. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  115. package/src/core/modules/schedules/scheduler.ts +43 -2
  116. package/src/core/modules/schedules/schedules.ts +43 -14
  117. package/src/core/ops/error-reporter.ts +24 -12
  118. package/src/core/plugin.ts +62 -0
  119. package/src/core/presence/direct-channels.ts +104 -0
  120. package/src/core/registry/contributions.ts +63 -3
  121. package/src/core/registry/providers.ts +1 -1
  122. package/src/core/routing/channel-router.ts +226 -17
  123. package/src/core/routing/conversation-turns.ts +85 -1
  124. package/src/core/routing/surface-port.ts +35 -2
  125. package/src/core/routing/system-turns.ts +29 -0
  126. package/src/core/runtime/bridge-guard.ts +225 -0
  127. package/src/core/runtime/compaction-tiers.ts +5 -1
  128. package/src/core/runtime/conversation-sessions.ts +124 -12
  129. package/src/core/runtime/extensions/ask-user.ts +1 -1
  130. package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
  131. package/src/core/runtime/extensions/private-memory.ts +447 -0
  132. package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
  133. package/src/core/runtime/pending-confirmation-store.ts +67 -4
  134. package/src/core/runtime/pi-agent-runtime.ts +143 -135
  135. package/src/core/runtime/preflight-tools.ts +47 -0
  136. package/src/core/runtime/prompt-slot.ts +6 -6
  137. package/src/core/runtime/reader-history.ts +129 -0
  138. package/src/core/runtime/runtime-plugin.ts +205 -0
  139. package/src/core/runtime/runtime-types.ts +40 -12
  140. package/src/core/runtime/session-conversation.ts +143 -0
  141. package/src/core/runtime/session-factory.ts +115 -15
  142. package/src/core/runtime/session-scope.ts +78 -0
  143. package/src/core/runtime/steerable-run.ts +9 -0
  144. package/src/core/runtime/tool-call-scope.ts +43 -0
  145. package/src/core/runtime/turn-answer.ts +30 -1
  146. package/src/core/runtime/turn-progress.ts +116 -0
  147. package/src/core/services.ts +42 -2
  148. package/src/core/sessions.ts +30 -0
  149. package/src/core/shared/unix-server.ts +35 -5
  150. package/src/core/speakers.ts +21 -19
  151. package/src/core/testing/background.ts +31 -0
  152. package/src/core/testing/fixture-database.ts +55 -0
  153. package/src/core/testing/map-identity.ts +76 -0
  154. package/src/core/testing/module-sessions.ts +134 -0
  155. package/src/core/testing/modules.ts +149 -38
  156. package/src/core/testing/owner.ts +17 -0
  157. package/src/core/testing/surface-contract.ts +460 -0
  158. package/src/core/testing/test-host.ts +100 -19
  159. package/src/core/testing/tool-set.ts +32 -0
  160. package/src/core/tool-set.snapshot.json +4 -4
  161. package/src/core/tool-tiers.ts +57 -3
  162. package/src/discord/index.ts +10 -0
  163. package/src/index.ts +65 -8
  164. package/src/kit/compaction.ts +13 -0
  165. package/src/kit/index.ts +10 -0
  166. package/src/kit/support.ts +1 -1
  167. package/src/testing.ts +96 -21
  168. package/templates/.env.example +1 -1
  169. package/templates/adapters/web/.env.example +24 -0
  170. package/templates/adapters/web/README.md +22 -0
  171. package/templates/adapters/web/package.json.tmpl +26 -0
  172. package/templates/adapters/web/persona/assistant.md +2 -0
  173. package/templates/adapters/web/roundtable.config.ts +59 -0
  174. package/templates/package.json.tmpl +1 -0
  175. package/templates/plugins/self-compact.ts +11 -0
  176. package/templates/roundtable.config.ts +14 -2
  177. package/src/core/domain/owner-prompts.ts +0 -45
@@ -1,8 +1,16 @@
1
1
  import type { AgentRuntime } from "../contract/runtime.ts";
2
2
  import type { SurfacePort } from "../contract/surface.ts";
3
+ import type {
4
+ ConversationRegistry,
5
+ ConversationVisibility,
6
+ } from "../conversations/conversation-registry.ts";
3
7
  import type { TurnAttachments } from "../domain/attachment.ts";
4
8
  import type { TurnResult } from "../domain/conversation.ts";
9
+ import type { TurnConversation } from "../domain/ports.ts";
10
+ import type { TurnProgress } from "../domain/progress.ts";
11
+ import { PluginError } from "../errors.ts";
5
12
  import { messages } from "../i18n/index.ts";
13
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
6
14
  import type { Logger } from "../log.ts";
7
15
  import type { EventSink } from "../plugin.ts";
8
16
  import { splitReply } from "../presentation/reply-splitter.ts";
@@ -36,6 +44,11 @@ export interface ConversationTurnInput {
36
44
  * notice); the claim formats its own. A throw is logged, never rethrown.
37
45
  */
38
46
  reply?(result: TurnResult): Promise<void>;
47
+ /**
48
+ * How the conversation is recorded at its first turn: `private` to the speaker, or `shared`
49
+ * (the default). Later turns keep what the first recorded, and give a title only then.
50
+ */
51
+ conversation?: { visibility: ConversationVisibility; title?: string };
39
52
  }
40
53
 
41
54
  /**
@@ -48,7 +61,13 @@ export interface ConversationTurns {
48
61
  * task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
49
62
  * `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
50
63
  * posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
51
- * It rejects during setup (NotLinkedError) and when the host has no runtime to run the turn on.
64
+ * It rejects during setup (NotLinkedError), when the host has no runtime to run the turn on,
65
+ * when the conversation cannot be recorded in the host's registry (a database error, or a
66
+ * `private` conversation without a speaker), and when the conversation is recorded private to a
67
+ * principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
68
+ * Each of these rejects before the turn starts:
69
+ * nothing is shown, no event is emitted, and `reply` is not called, so a claim that answers
70
+ * its person tells them itself.
52
71
  */
53
72
  run(input: ConversationTurnInput): Promise<TurnResult>;
54
73
  }
@@ -58,6 +77,8 @@ export interface ConversationTurnsOptions {
58
77
  linked: () => void;
59
78
  /** The runtime that runs the turn; throws PluginError when the host has none yet. */
60
79
  runtime: () => AgentRuntime;
80
+ /** Where each turn records its conversation; absent, or undefined, records none. */
81
+ registry?: () => Pick<ConversationRegistry, "register"> | undefined;
61
82
  surfaces: SurfacePort;
62
83
  events: EventSink;
63
84
  /** The plugins' agent selection, read at each turn that gives none of its own. */
@@ -68,6 +89,52 @@ export interface ConversationTurnsOptions {
68
89
  /** The selection of a turn that gives none: the plugins' `agentSelection`. */
69
90
  const DEFAULT_SELECTION = "turns";
70
91
 
92
+ /**
93
+ * Records the conversation of a turn, private to the speaker when asked, else shared, and returns
94
+ * who it belongs to as the record keeps it; without a registry, as the turn asks, if it does.
95
+ */
96
+ async function record(
97
+ registry: Pick<ConversationRegistry, "register"> | undefined,
98
+ input: ConversationTurnInput,
99
+ ): Promise<TurnConversation | undefined> {
100
+ const visibility = input.conversation?.visibility ?? "shared";
101
+ if (visibility === "private" && !input.speaker)
102
+ throw new PluginError(
103
+ `a private conversation needs the speaker it belongs to; ${input.channel} was run without one.`,
104
+ );
105
+ if (!registry)
106
+ return input.conversation && conversationOf(visibility, input.speaker);
107
+ const title = input.conversation?.title;
108
+ const kept = await registry.register({
109
+ key: input.channel,
110
+ kind: input.kind,
111
+ visibility,
112
+ ...(visibility === "private"
113
+ ? { principalId: input.speaker.principalId }
114
+ : {}),
115
+ ...(title === undefined ? {} : { title }),
116
+ });
117
+ if (kept.visibility !== "private" || kept.principalId === undefined)
118
+ return { visibility: "shared" };
119
+ // Only its person, or the host itself, speaks in a private conversation: anyone else's turn would
120
+ // read and act on what is theirs.
121
+ const { principalId } = input.speaker;
122
+ if (principalId !== kept.principalId && principalId !== SYSTEM_PRINCIPAL)
123
+ throw new PluginError(
124
+ `${input.channel} is private to ${kept.principalId}, so ${principalId} may not speak in it; run their turn in a conversation of their own.`,
125
+ );
126
+ return { visibility: "private", principalId: kept.principalId };
127
+ }
128
+
129
+ function conversationOf(
130
+ visibility: ConversationVisibility,
131
+ speaker: Speaker,
132
+ ): TurnConversation {
133
+ return visibility === "private"
134
+ ? { visibility, principalId: speaker.principalId }
135
+ : { visibility };
136
+ }
137
+
71
138
  export function conversationTurns(
72
139
  options: ConversationTurnsOptions,
73
140
  ): ConversationTurns {
@@ -78,12 +145,26 @@ export function conversationTurns(
78
145
  options.linked();
79
146
  // A host without a runtime is a setup mistake, so it is refused before anything is shown.
80
147
  const runtime = options.runtime();
148
+ // A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
149
+ const conversation = await record(options.registry?.(), input);
81
150
  const stopTyping = surfaces.startTyping(channel);
82
151
  const hideStop = surfaces.showStop(channel);
83
152
  const turn = { kind, channel, speaker };
84
153
  events.turnStarted(turn);
85
154
  // A claim that posts its own reply formats its own text, so only the default reply posts as it goes.
86
155
  const interim = input.reply ? undefined : surfaces.interim(channel);
156
+ // What the runtime reports as it goes reaches the surface and the handlers until the turn ends.
157
+ let live = true;
158
+ const progress = (event: TurnProgress) => {
159
+ if (!live) return;
160
+ events.turnProgress?.({ ...turn, progress: event });
161
+ // pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
162
+ surfaces
163
+ .progress?.(channel, event)
164
+ ?.catch((error: unknown) =>
165
+ logger.warn({ channel, kind, err: error }, "progress not shown"),
166
+ );
167
+ };
87
168
  let result: TurnResult;
88
169
  try {
89
170
  result = await settleTurn(
@@ -98,6 +179,7 @@ export function conversationTurns(
98
179
  },
99
180
  text: input.text,
100
181
  speaker,
182
+ ...(conversation ? { conversation } : {}),
101
183
  ...(input.attachments
102
184
  ? { attachments: input.attachments }
103
185
  : {}),
@@ -105,11 +187,13 @@ export function conversationTurns(
105
187
  ...(input.steerable ? { steerable: true } : {}),
106
188
  ...(input.interactive ? { interactive: true } : {}),
107
189
  ...(interim ? { interim } : {}),
190
+ progress,
108
191
  }),
109
192
  ),
110
193
  "conversation turn",
111
194
  );
112
195
  } finally {
196
+ live = false;
113
197
  hideStop();
114
198
  }
115
199
  events.turnEnded({
@@ -1,13 +1,27 @@
1
1
  import type { ChatSurface, SurfacePort } from "../contract/surface.ts";
2
2
  import { parseChannelKey } from "../contract/surface.ts";
3
3
  import { PluginError } from "../errors.ts";
4
+ import {
5
+ isPromptScope,
6
+ type PromptScope,
7
+ promptScope,
8
+ } from "../interactions/prompts.ts";
4
9
  import type { ChannelKey } from "../sessions.ts";
10
+ import type { Speaker } from "../speakers.ts";
11
+
12
+ export interface SurfacePortOptions {
13
+ /** Told when a caller uses a form that goes away, such as `prompts(channel, speaker)`. */
14
+ deprecated?(message: string): void;
15
+ }
5
16
 
6
17
  /**
7
18
  * The port over the contributed surfaces, each chosen by the prefix of a channel's key. `linked`
8
19
  * gives the surfaces once every plugin is set up and throws NotLinkedError before.
9
20
  */
10
- export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
21
+ export function surfacePort(
22
+ linked: () => readonly ChatSurface[],
23
+ options: SurfacePortOptions = {},
24
+ ): SurfacePort {
11
25
  const of = (channel: ChannelKey): ChatSurface | undefined => {
12
26
  const at = channel.indexOf(":");
13
27
  if (at <= 0) return undefined;
@@ -40,7 +54,26 @@ export function surfacePort(linked: () => readonly ChatSurface[]): SurfacePort {
40
54
  of(channel)?.react?.(channel, messageId, emoji),
41
55
  unreact: async (channel, messageId, emoji) =>
42
56
  of(channel)?.unreact?.(channel, messageId, emoji),
43
- prompts: (channel, speaker) => of(channel)?.prompts?.(channel, speaker),
57
+ prompts: (channel, scope) =>
58
+ of(channel)?.prompts?.(channel, scopeOf(scope, options)),
44
59
  interim: (channel) => of(channel)?.interim?.(channel),
60
+ progress: async (channel, event) => {
61
+ await of(channel)?.progress?.(channel, event);
62
+ },
45
63
  };
46
64
  }
65
+
66
+ /**
67
+ * The scope a surface's prompts get: as given, or, from the 0.8 form's speaker, theirs in a
68
+ * shared conversation, as 0.8 had them, with a warning.
69
+ */
70
+ function scopeOf(
71
+ given: PromptScope | Speaker | undefined,
72
+ options: SurfacePortOptions,
73
+ ): PromptScope | undefined {
74
+ if (!given || isPromptScope(given)) return given;
75
+ options.deprecated?.(
76
+ "deprecated: prompts(channel, speaker) takes a PromptScope since 0.9, made with promptScope(speaker, visibility); a speaker is read as theirs in a shared conversation, and this form goes away in 1.0",
77
+ );
78
+ return promptScope(given);
79
+ }
@@ -0,0 +1,29 @@
1
+ import type { BackgroundTurn } from "../contract/channels.ts";
2
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
3
+
4
+ /** The turns the host itself made, by identity: a copy, or one a plugin writes, is not one of them. */
5
+ const made = new WeakSet<BackgroundTurn>();
6
+
7
+ /**
8
+ * A background turn the host itself starts as the system principal, such as a logged error's
9
+ * report or a Discord webhook's post, at the tier its starter gives; the router runs it as the
10
+ * system principal's speaker. Core-internal: a plugin cannot make one, and a turn it writes naming
11
+ * the system principal is skipped.
12
+ */
13
+ export function systemTurn(
14
+ turn: Omit<BackgroundTurn, "author" | "speaker"> & {
15
+ author: { id: string; name: string };
16
+ },
17
+ ): BackgroundTurn {
18
+ const own: BackgroundTurn = {
19
+ ...turn,
20
+ author: { ...turn.author, principalId: SYSTEM_PRINCIPAL },
21
+ };
22
+ made.add(own);
23
+ return own;
24
+ }
25
+
26
+ /** Whether the host made this very turn with `systemTurn`. */
27
+ export function isSystemTurn(turn: BackgroundTurn): boolean {
28
+ return made.has(turn);
29
+ }
@@ -0,0 +1,225 @@
1
+ import type {
2
+ AgentSession,
3
+ ContextWithSystemEvent,
4
+ ModelRuntime,
5
+ SessionManager,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import type { AgentSessions } from "../contract/runtime.ts";
8
+ import { AgentRunError, ConfigError } from "../domain/errors.ts";
9
+ import type { AccessRules, AccessTier } from "../identity/access-policy.ts";
10
+ import type { IdentityService } from "../identity/identity-service.ts";
11
+ import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
12
+ import {
13
+ formatModelRef,
14
+ type ModelRef,
15
+ parseModelRef,
16
+ type ThinkingSetting,
17
+ } from "../models.ts";
18
+ import type { RoundtablePlugin } from "../plugin.ts";
19
+ import { guardHidesPrivateExchange } from "./extensions/private-memory.ts";
20
+ import { bridgeHistoryHidesMemoryFor } from "./reader-history.ts";
21
+
22
+ /**
23
+ * The Pi provider pi-claude-bridge registers. Its sessions resume the history Claude Code stored
24
+ * for a conversation, tool results as they came, rather than each request the core projects, so
25
+ * the core's per-request hiding of private memory does not reach it.
26
+ */
27
+ export const CLAUDE_BRIDGE = "claude-bridge";
28
+
29
+ /** Whether the model runs on claude-bridge, by its provider id. */
30
+ export function onClaudeBridge(model: { provider: string }): boolean {
31
+ return model.provider === CLAUDE_BRIDGE;
32
+ }
33
+
34
+ /**
35
+ * Whether the tier admits someone besides the owners: everyone, a role, which anyone may hold, or
36
+ * an identity that is not already an owner's, since an owner listed again is still one person.
37
+ */
38
+ const admits = (
39
+ tier: AccessTier | undefined,
40
+ owners: ReadonlySet<string>,
41
+ ): boolean => {
42
+ if (!tier) return false;
43
+ const { everyone } = tier;
44
+ if (everyone === true || (Array.isArray(everyone) && everyone.length > 0))
45
+ return true;
46
+ return (
47
+ (tier.identities ?? []).some((identity) => !owners.has(identity)) ||
48
+ (tier.roles?.length ?? 0) > 0
49
+ );
50
+ };
51
+
52
+ /**
53
+ * Why, by the configuration, a shared conversation of the host may have more than one person
54
+ * speaking in it; undefined when the one owner alone does. More than one owner, rules that admit
55
+ * admins or members besides the owner's own identities, or a plugin's identity bound to a
56
+ * principal other than the primary owner's.
57
+ */
58
+ export function configuredCrowd(
59
+ rules: Pick<AccessRules, "owners" | "admins" | "members">,
60
+ plugins: readonly Pick<RoundtablePlugin, "name" | "identities">[],
61
+ ): string | undefined {
62
+ if (rules.owners.length > 1)
63
+ return `access.owners names ${rules.owners.length} owners`;
64
+ const owners = new Set(rules.owners.flatMap((owner) => owner.identities));
65
+ for (const tier of ["admins", "members"] as const)
66
+ if (admits(rules[tier], owners))
67
+ return `access.${tier} admits people besides the owner`;
68
+ const primary = rules.owners[0]?.principal;
69
+ for (const plugin of plugins)
70
+ for (const { identity, principal } of plugin.identities ?? [])
71
+ if (principal !== undefined && principal !== primary)
72
+ return `plugin ${plugin.name} binds ${identity} to principal ${principal}, not the owner's`;
73
+ return undefined;
74
+ }
75
+
76
+ /**
77
+ * Why, by the roles the host stores, such as those `roundtable principal grant` gives, a shared
78
+ * conversation may have more than one person speaking in it; undefined when only the primary
79
+ * owner holds a lasting role.
80
+ */
81
+ async function grantedCrowd(
82
+ identity: Pick<IdentityService, "list" | "tierOf" | "owners">,
83
+ primary: string,
84
+ ): Promise<string | undefined> {
85
+ const owners = await identity.owners();
86
+ if (owners.length > 1)
87
+ return `${owners.length} principals hold the owner role: ${owners.map((owner) => owner.id).join(", ")}`;
88
+ for (const principal of await identity.list()) {
89
+ if (principal.id === primary || principal.id === SYSTEM_PRINCIPAL) continue;
90
+ const tier = await identity.tierOf(principal.id);
91
+ if (tier) return `principal ${principal.id} holds the ${tier} role`;
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /** Why claude-bridge may not serve a host whose shared conversations hold several people's memory. */
97
+ export function bridgeMemoryProblem(model: string, crowd: string): string {
98
+ return `model ${model} runs on ${CLAUDE_BRIDGE}, and this host keeps each person's private memory while ${crowd}. ${CLAUDE_BRIDGE} resumes the history Claude Code stored for a conversation, unfiltered, so one person's private memory could reach another reader. Shared bridge turns refuse when raw reader records show private memory carried for another reader (including prompt-only memory and reasoning), or an exchange is inaccessible. Turns without private memory stay shareable; malformed memory calls belong to their recorded reader. SYSTEM counts as primary owner and may read that owner's raw exchanges/reasoning; untagged pre-scope legacy memory results keep primary-owner attribution. Public replies, untagged custom tools, and old prompts/reasoning/summaries without reader records remain a risk.`;
99
+ }
100
+
101
+ /** How to fix it. */
102
+ export const BRIDGE_MEMORY_FIX =
103
+ "Use another provider, or start a fresh conversation before sharing incompatible private history. memory: false or persona memory: none prevents future core memory loading, but does not bypass recorded private history.";
104
+
105
+ /** The refusal of claude-bridge on a host whose shared conversations hold several people's memory. */
106
+ export function bridgeMemoryError(model: string, crowd: string): ConfigError {
107
+ return new ConfigError(
108
+ `${bridgeMemoryProblem(model, crowd)} ${BRIDGE_MEMORY_FIX}`,
109
+ );
110
+ }
111
+
112
+ export interface AgentModelSettings {
113
+ model: string;
114
+ thinking: ThinkingSetting;
115
+ }
116
+
117
+ /** Copy the turn's selected model/settings once: checking and using separate reads can race a switch. */
118
+ export function chosenAgentModel(
119
+ agent: string | undefined,
120
+ options: { agents?: Pick<AgentSessions, "modelOf"> },
121
+ ): AgentModelSettings | undefined {
122
+ return agent !== undefined && options.agents
123
+ ? { ...options.agents.modelOf(agent) }
124
+ : undefined;
125
+ }
126
+
127
+ /** Apply exactly the model snapshot the guard checked, never a later live settings read. */
128
+ export async function useChosenAgentModel(
129
+ session: AgentSession,
130
+ modelRuntime: Pick<ModelRuntime, "getModel">,
131
+ name: string,
132
+ chosen: AgentModelSettings | undefined,
133
+ ): Promise<ThinkingSetting> {
134
+ if (!chosen)
135
+ throw new ConfigError("agent turns need the runtime's agents option");
136
+ const { model, thinking } = chosen;
137
+ const ref = parseModelRef(model);
138
+ if (
139
+ session.model?.provider !== ref?.provider ||
140
+ session.model?.id !== ref?.id
141
+ ) {
142
+ const resolved = ref && modelRuntime.getModel(ref.provider, ref.id);
143
+ if (!resolved)
144
+ throw new AgentRunError(
145
+ `${name}'s model ${model} is not available on this host`,
146
+ );
147
+ await session.setModel(resolved);
148
+ }
149
+ return thinking;
150
+ }
151
+
152
+ interface BridgeHistory {
153
+ messages: ContextWithSystemEvent["messages"];
154
+ reader: string | undefined;
155
+ /** The persisted branch retains exchanges removed from the active context by compaction. */
156
+ sessionManager?: Pick<SessionManager, "getBranch">;
157
+ }
158
+
159
+ /** Only results recorded before the first 0.9 scope marker inherit legacy owner attribution. */
160
+ function hidesBridgeHistory(
161
+ history: BridgeHistory,
162
+ reader: string | undefined,
163
+ primary: string,
164
+ ): boolean {
165
+ const view = { shared: true, reader };
166
+ const branch = history.sessionManager?.getBranch();
167
+ if (!branch)
168
+ return guardHidesPrivateExchange(history.messages, {
169
+ ...view,
170
+ legacyOwner: primary,
171
+ });
172
+ return bridgeHistoryHidesMemoryFor(branch, reader, primary);
173
+ }
174
+
175
+ /**
176
+ * Why raw private history prevents a shared turn from running on its selected bridge model;
177
+ * undefined when it may. The model is the agent's own for an agent's turn, the host's otherwise.
178
+ */
179
+ export async function bridgeTurnRefusal(
180
+ agent: string | undefined,
181
+ options: {
182
+ model: ModelRef;
183
+ agents?: Pick<AgentSessions, "modelOf"> | undefined;
184
+ memory?: boolean;
185
+ owner: { id: string };
186
+ },
187
+ history: BridgeHistory,
188
+ chosen?: AgentModelSettings,
189
+ ): Promise<string | undefined> {
190
+ const model =
191
+ agent === undefined
192
+ ? options.model
193
+ : parseModelRef(
194
+ chosen?.model ?? options.agents?.modelOf(agent).model ?? "",
195
+ );
196
+ if (!model || !onClaudeBridge(model)) return undefined;
197
+ const reader =
198
+ history.reader === SYSTEM_PRINCIPAL ? options.owner.id : history.reader;
199
+ if (!hidesBridgeHistory(history, reader, options.owner.id)) return undefined;
200
+ const why =
201
+ "this conversation's raw history holds another reader's private memory or a private exchange the current reader may not see";
202
+ const message = bridgeMemoryError(formatModelRef(model), why).message;
203
+ return agent === undefined ? message : `${agent}: ${message}`;
204
+ }
205
+
206
+ /**
207
+ * Why startup should warn about claude-bridge: the configuration's crowd, or
208
+ * the roles the host stores as they are now, so one granted after the boot counts at once.
209
+ * Undefined while one person alone may speak, or the host keeps no memory.
210
+ */
211
+ export function liveBridgeRefusal(options: {
212
+ memory: boolean | undefined;
213
+ crowd: string | undefined;
214
+ identity: () =>
215
+ | Pick<IdentityService, "list" | "tierOf" | "owners">
216
+ | undefined;
217
+ primary: string;
218
+ }): () => Promise<string | undefined> {
219
+ return async () => {
220
+ if (!options.memory) return undefined;
221
+ if (options.crowd) return options.crowd;
222
+ const identity = options.identity();
223
+ return identity && (await grantedCrowd(identity, options.primary));
224
+ };
225
+ }
@@ -164,16 +164,20 @@ export class CompactionTiers {
164
164
  /**
165
165
  * Wraps a compaction extension so a compaction past the hard ceiling skips it and pi's summary
166
166
  * runs. pi keeps the last handler result and stops only on cancel, so a handler of the assistant's
167
- * own could not keep the extension from answering; wrapping its registration can.
167
+ * own could not keep the extension from answering; wrapping its registration can. `prepare`
168
+ * changes what the extension is given before it reads it, such as a shared conversation's
169
+ * history without anyone's private memory.
168
170
  */
169
171
  wrapCompactor(
170
172
  compactor: ExtensionFactory,
171
173
  onBypass: (details: { reason: string; tokensBefore: number }) => void,
174
+ prepare?: (event: SessionBeforeCompactEvent) => void,
172
175
  ): ExtensionFactory {
173
176
  const gate = (event: SessionBeforeCompactEvent): boolean => {
174
177
  const { tokensBefore } = event.preparation;
175
178
  const reason = extensionBypass(tokensBefore, this.latest());
176
179
  if (reason) onBypass({ reason, tokensBefore });
180
+ else prepare?.(event);
177
181
  return reason === undefined;
178
182
  };
179
183
  type Handler = (event: unknown, ctx: unknown) => unknown;