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.
- package/CHANGELOG.md +115 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +618 -86
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- 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 +5 -2
- 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 +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- 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/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +150 -86
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +87 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +82 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +42 -11
- 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-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 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +52 -68
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/i18n/conversation.ts +2 -0
- 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 +56 -4
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +274 -21
- package/src/core/routing/conversation-turns.ts +45 -10
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/abort-guard.ts +58 -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 +139 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +174 -190
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +51 -5
- package/src/core/runtime/runtime-types.ts +48 -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 +21 -3
- package/src/core/runtime/thinking-level.ts +47 -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-wrapup.ts +63 -0
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- 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 +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- 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 +8 -0
- package/src/index.ts +48 -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 +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -6,9 +6,11 @@ import type {
|
|
|
6
6
|
} from "../conversations/conversation-registry.ts";
|
|
7
7
|
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
8
8
|
import type { TurnResult } from "../domain/conversation.ts";
|
|
9
|
+
import type { TurnConversation } from "../domain/ports.ts";
|
|
9
10
|
import type { TurnProgress } from "../domain/progress.ts";
|
|
10
|
-
import { PluginError } from "../errors.ts";
|
|
11
|
+
import { HostStoppingError, PluginError } from "../errors.ts";
|
|
11
12
|
import { messages } from "../i18n/index.ts";
|
|
13
|
+
import { SYSTEM_PRINCIPAL } from "../identity/principal-store.ts";
|
|
12
14
|
import type { Logger } from "../log.ts";
|
|
13
15
|
import type { EventSink } from "../plugin.ts";
|
|
14
16
|
import { splitReply } from "../presentation/reply-splitter.ts";
|
|
@@ -59,9 +61,12 @@ export interface ConversationTurns {
|
|
|
59
61
|
* task. It shows typing and the stop control on the channel's surface, emits `turnStarted` and
|
|
60
62
|
* `turnEnded` with the turn's `kind`, settles a runtime that throws into a failed result, and
|
|
61
63
|
* posts the reply, or a failure or stopped notice, through the surface unless `reply` is given.
|
|
62
|
-
* It rejects during setup (NotLinkedError),
|
|
63
|
-
*
|
|
64
|
-
*
|
|
64
|
+
* It rejects during setup (NotLinkedError), once the host is shutting down (HostStoppingError),
|
|
65
|
+
* when the host has no runtime to run the turn on,
|
|
66
|
+
* when the conversation cannot be recorded in the host's registry (a database error, or a
|
|
67
|
+
* `private` conversation without a speaker), and when the conversation is recorded private to a
|
|
68
|
+
* principal other than the speaker's, unless the speaker is the host's own (`SYSTEM_PRINCIPAL`).
|
|
69
|
+
* Each of these rejects before the turn starts:
|
|
65
70
|
* nothing is shown, no event is emitted, and `reply` is not called, so a claim that answers
|
|
66
71
|
* its person tells them itself.
|
|
67
72
|
*/
|
|
@@ -71,6 +76,8 @@ export interface ConversationTurns {
|
|
|
71
76
|
export interface ConversationTurnsOptions {
|
|
72
77
|
/** Throws NotLinkedError until every plugin is set up and linked. */
|
|
73
78
|
linked: () => void;
|
|
79
|
+
/** Whether the host is shutting down, so no turn starts; undefined or false while it serves. */
|
|
80
|
+
stopping?: () => boolean;
|
|
74
81
|
/** The runtime that runs the turn; throws PluginError when the host has none yet. */
|
|
75
82
|
runtime: () => AgentRuntime;
|
|
76
83
|
/** Where each turn records its conversation; absent, or undefined, records none. */
|
|
@@ -85,25 +92,50 @@ export interface ConversationTurnsOptions {
|
|
|
85
92
|
/** The selection of a turn that gives none: the plugins' `agentSelection`. */
|
|
86
93
|
const DEFAULT_SELECTION = "turns";
|
|
87
94
|
|
|
88
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* Records the conversation of a turn, private to the speaker when asked, else shared, and returns
|
|
97
|
+
* who it belongs to as the record keeps it; without a registry, as the turn asks, if it does.
|
|
98
|
+
*/
|
|
89
99
|
async function record(
|
|
90
100
|
registry: Pick<ConversationRegistry, "register"> | undefined,
|
|
91
101
|
input: ConversationTurnInput,
|
|
92
|
-
): Promise<
|
|
93
|
-
if (!registry) return;
|
|
102
|
+
): Promise<TurnConversation | undefined> {
|
|
94
103
|
const visibility = input.conversation?.visibility ?? "shared";
|
|
95
104
|
if (visibility === "private" && !input.speaker)
|
|
96
105
|
throw new PluginError(
|
|
97
106
|
`a private conversation needs the speaker it belongs to; ${input.channel} was run without one.`,
|
|
98
107
|
);
|
|
108
|
+
if (!registry)
|
|
109
|
+
return input.conversation && conversationOf(visibility, input.speaker);
|
|
99
110
|
const title = input.conversation?.title;
|
|
100
|
-
await registry.register({
|
|
111
|
+
const kept = await registry.register({
|
|
101
112
|
key: input.channel,
|
|
102
113
|
kind: input.kind,
|
|
103
114
|
visibility,
|
|
104
|
-
...(visibility === "private"
|
|
115
|
+
...(visibility === "private"
|
|
116
|
+
? { principalId: input.speaker.principalId }
|
|
117
|
+
: {}),
|
|
105
118
|
...(title === undefined ? {} : { title }),
|
|
106
119
|
});
|
|
120
|
+
if (kept.visibility !== "private" || kept.principalId === undefined)
|
|
121
|
+
return { visibility: "shared" };
|
|
122
|
+
// Only its person, or the host itself, speaks in a private conversation: anyone else's turn would
|
|
123
|
+
// read and act on what is theirs.
|
|
124
|
+
const { principalId } = input.speaker;
|
|
125
|
+
if (principalId !== kept.principalId && principalId !== SYSTEM_PRINCIPAL)
|
|
126
|
+
throw new PluginError(
|
|
127
|
+
`${input.channel} is private to ${kept.principalId}, so ${principalId} may not speak in it; run their turn in a conversation of their own.`,
|
|
128
|
+
);
|
|
129
|
+
return { visibility: "private", principalId: kept.principalId };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function conversationOf(
|
|
133
|
+
visibility: ConversationVisibility,
|
|
134
|
+
speaker: Speaker,
|
|
135
|
+
): TurnConversation {
|
|
136
|
+
return visibility === "private"
|
|
137
|
+
? { visibility, principalId: speaker.principalId }
|
|
138
|
+
: { visibility };
|
|
107
139
|
}
|
|
108
140
|
|
|
109
141
|
export function conversationTurns(
|
|
@@ -114,10 +146,12 @@ export function conversationTurns(
|
|
|
114
146
|
async run(input) {
|
|
115
147
|
const { channel, kind, speaker } = input;
|
|
116
148
|
options.linked();
|
|
149
|
+
// Refused before anything is shown or recorded, like a turn the host cannot run.
|
|
150
|
+
if (options.stopping?.()) throw new HostStoppingError();
|
|
117
151
|
// A host without a runtime is a setup mistake, so it is refused before anything is shown.
|
|
118
152
|
const runtime = options.runtime();
|
|
119
153
|
// A conversation that cannot be recorded runs no turn: who it belongs to would be lost.
|
|
120
|
-
await record(options.registry?.(), input);
|
|
154
|
+
const conversation = await record(options.registry?.(), input);
|
|
121
155
|
const stopTyping = surfaces.startTyping(channel);
|
|
122
156
|
const hideStop = surfaces.showStop(channel);
|
|
123
157
|
const turn = { kind, channel, speaker };
|
|
@@ -150,6 +184,7 @@ export function conversationTurns(
|
|
|
150
184
|
},
|
|
151
185
|
text: input.text,
|
|
152
186
|
speaker,
|
|
187
|
+
...(conversation ? { conversation } : {}),
|
|
153
188
|
...(input.attachments
|
|
154
189
|
? { attachments: input.attachments }
|
|
155
190
|
: {}),
|
|
@@ -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,10 +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),
|
|
45
60
|
progress: async (channel, event) => {
|
|
46
61
|
await of(channel)?.progress?.(channel, event);
|
|
47
62
|
},
|
|
48
63
|
};
|
|
49
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,58 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
|
|
4
|
+
/** How long a run told to abort gets to end before its session is disposed of; default for the guard. */
|
|
5
|
+
const ABORT_GRACE_MS = 15_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Aborts a Pi session's run and does not trust the abort to work. A model behind a subprocess that
|
|
9
|
+
* ignores the abort would leave the run, and the queue behind it, waiting for ever, so a run still
|
|
10
|
+
* going once the grace is over has its session disposed of, which drops its model connection, and
|
|
11
|
+
* `gaveUp` rejects so the caller can end the turn as failed.
|
|
12
|
+
*/
|
|
13
|
+
export class AbortGuard {
|
|
14
|
+
/** Rejects when the abort did not end the run in time; never resolves. */
|
|
15
|
+
readonly gaveUp: Promise<never>;
|
|
16
|
+
readonly #session: Pick<AgentSession, "abort" | "dispose">;
|
|
17
|
+
readonly #graceMs: number;
|
|
18
|
+
readonly #onGiveUp: () => void;
|
|
19
|
+
#reject: (error: Error) => void = () => undefined;
|
|
20
|
+
#timer: ReturnType<typeof setTimeout> | undefined;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
session: Pick<AgentSession, "abort" | "dispose">,
|
|
24
|
+
options: { graceMs?: number; onGiveUp?: () => void } = {},
|
|
25
|
+
) {
|
|
26
|
+
this.#session = session;
|
|
27
|
+
this.#graceMs = options.graceMs ?? ABORT_GRACE_MS;
|
|
28
|
+
this.#onGiveUp = options.onGiveUp ?? (() => undefined);
|
|
29
|
+
this.gaveUp = new Promise<never>((_resolve, reject) => {
|
|
30
|
+
this.#reject = reject;
|
|
31
|
+
});
|
|
32
|
+
// Nobody waits on it unless the abort fails.
|
|
33
|
+
this.gaveUp.catch(() => undefined);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Aborts the run, and starts the grace the first time. */
|
|
37
|
+
abort(): void {
|
|
38
|
+
void this.#session.abort();
|
|
39
|
+
this.#timer ??= setTimeout(() => {
|
|
40
|
+
this.#onGiveUp();
|
|
41
|
+
try {
|
|
42
|
+
this.#session.dispose();
|
|
43
|
+
} catch {
|
|
44
|
+
// A session that cannot be disposed of is dropped all the same.
|
|
45
|
+
}
|
|
46
|
+
this.#reject(
|
|
47
|
+
new AgentRunError(
|
|
48
|
+
"the run did not stop after it was aborted, so its session was disposed of",
|
|
49
|
+
),
|
|
50
|
+
);
|
|
51
|
+
}, this.#graceMs);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The run ended: no session is disposed of any more. */
|
|
55
|
+
release(): void {
|
|
56
|
+
clearTimeout(this.#timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -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;
|