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.
- package/CHANGELOG.md +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +7 -3
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-adapter.ts +9 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +10 -0
- package/src/index.ts +65 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- 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)
|
|
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(
|
|
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,
|
|
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;
|