pi-roundtable 0.8.0 → 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 +102 -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 +606 -78
- 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 +140 -85
- 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 +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -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 +41 -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/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- 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/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-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -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 +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 +135 -134
- 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 +50 -5
- 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/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 +47 -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
|
@@ -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;
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
|
|
4
4
|
import type {
|
|
5
5
|
ChannelKey,
|
|
6
6
|
PendingConfirmation,
|
|
7
7
|
} from "../domain/conversation.ts";
|
|
8
|
-
import type { TurnRequest } from "../domain/ports.ts";
|
|
8
|
+
import type { TurnConversation, TurnRequest } from "../domain/ports.ts";
|
|
9
9
|
import type { ThinkingLevel } from "../models.ts";
|
|
10
10
|
import type { ToolTiers } from "../tool-tiers.ts";
|
|
11
|
-
import { ConfirmationGate } from "./extensions/confirmation-gate.ts";
|
|
11
|
+
import { ConfirmationGate, unexpired } from "./extensions/confirmation-gate.ts";
|
|
12
12
|
import { PromptSlot } from "./prompt-slot.ts";
|
|
13
13
|
import type { ChannelSession, PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
14
14
|
import { revisionsKey, skillsKey } from "./runtime-types.ts";
|
|
15
|
+
import {
|
|
16
|
+
conversationChanged,
|
|
17
|
+
namedConversation,
|
|
18
|
+
sessionConversation,
|
|
19
|
+
} from "./session-conversation.ts";
|
|
15
20
|
import type { SessionFactory } from "./session-factory.ts";
|
|
21
|
+
import {
|
|
22
|
+
historyMessages,
|
|
23
|
+
historyScope,
|
|
24
|
+
scopedHistory,
|
|
25
|
+
} from "./session-scope.ts";
|
|
26
|
+
|
|
27
|
+
type AgentMessage = AgentSession["messages"][number];
|
|
16
28
|
|
|
17
29
|
/** The open sessions of a runtime's conversations and what is held for each of them. */
|
|
18
30
|
export class ConversationSessions {
|
|
@@ -21,6 +33,11 @@ export class ConversationSessions {
|
|
|
21
33
|
readonly #tiers: ToolTiers;
|
|
22
34
|
readonly #sessions = new Map<ChannelKey, Promise<ChannelSession>>();
|
|
23
35
|
readonly #gates = new Map<ChannelKey, ConfirmationGate>();
|
|
36
|
+
/**
|
|
37
|
+
* What the store held for a conversation that has no gate yet, once read: a host asks what is
|
|
38
|
+
* pending before the conversation's first turn after a restart, which builds its gate.
|
|
39
|
+
*/
|
|
40
|
+
readonly #restored = new Map<ChannelKey, PendingConfirmation | undefined>();
|
|
24
41
|
/** Each conversation's way to ask the owner during its current turn. */
|
|
25
42
|
readonly #slots = new Map<ChannelKey, PromptSlot>();
|
|
26
43
|
/** Each conversation's level the judge picked for its last turn, kept while the judge is unsure. */
|
|
@@ -41,9 +58,24 @@ export class ConversationSessions {
|
|
|
41
58
|
this.#tiers = tiers;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
/** The held actions of a conversation whose gate is open. */
|
|
61
|
+
/** The held actions of a conversation whose gate is open, or that `held` has restored from the store. */
|
|
45
62
|
pending(channel: ChannelKey): PendingConfirmation | undefined {
|
|
46
|
-
|
|
63
|
+
const gate = this.#gates.get(channel);
|
|
64
|
+
return gate ? gate.pending() : unexpired(this.#restored.get(channel));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The conversation's held actions, restored from the store the first time they are asked for
|
|
69
|
+
* after a restart. Builds neither a gate nor a session: a gate's flavour follows the turn that
|
|
70
|
+
* first needs it, an agent's or not, and only the turn knows which.
|
|
71
|
+
*/
|
|
72
|
+
async held(key: ChannelKey): Promise<PendingConfirmation | undefined> {
|
|
73
|
+
if (!this.#gates.has(key) && !this.#restored.has(key)) {
|
|
74
|
+
const stored = await this.#options.confirmations.load(key);
|
|
75
|
+
// A turn may have built the gate while the store was read; its held actions are the later ones.
|
|
76
|
+
if (!this.#gates.has(key)) this.#restored.set(key, stored);
|
|
77
|
+
}
|
|
78
|
+
return this.pending(key);
|
|
47
79
|
}
|
|
48
80
|
|
|
49
81
|
/** Disposes every open session. */
|
|
@@ -77,6 +109,7 @@ export class ConversationSessions {
|
|
|
77
109
|
this.#tiers,
|
|
78
110
|
);
|
|
79
111
|
this.#gates.set(key, gate);
|
|
112
|
+
this.#restored.delete(key);
|
|
80
113
|
}
|
|
81
114
|
return gate;
|
|
82
115
|
}
|
|
@@ -96,7 +129,7 @@ export class ConversationSessions {
|
|
|
96
129
|
*/
|
|
97
130
|
session(
|
|
98
131
|
key: ChannelKey,
|
|
99
|
-
request?: Pick<TurnRequest, "channel" | "agent" | "kind">,
|
|
132
|
+
request?: Pick<TurnRequest, "channel" | "agent" | "kind" | "conversation">,
|
|
100
133
|
): Promise<ChannelSession> {
|
|
101
134
|
let pending = this.#sessions.get(key);
|
|
102
135
|
if (!pending) {
|
|
@@ -104,28 +137,87 @@ export class ConversationSessions {
|
|
|
104
137
|
mkdirSync(sessionDir, { recursive: true });
|
|
105
138
|
const channel = request?.channel ?? key;
|
|
106
139
|
const agent = request?.agent;
|
|
107
|
-
pending = (async () =>
|
|
108
|
-
|
|
140
|
+
pending = (async () => {
|
|
141
|
+
const conversation = await sessionConversation(
|
|
142
|
+
key,
|
|
143
|
+
request,
|
|
144
|
+
this.#options,
|
|
145
|
+
);
|
|
146
|
+
// A history of another scope is archived, never replayed, and what it held goes with it,
|
|
147
|
+
// after a restart too: no one approves, or reads, an action held for someone else.
|
|
148
|
+
const { history, archived } = scopedHistory(
|
|
149
|
+
sessionDir,
|
|
150
|
+
this.#factory.cwd(agent),
|
|
151
|
+
conversation,
|
|
152
|
+
);
|
|
153
|
+
if (archived > 0) {
|
|
154
|
+
this.#options.logger.info(
|
|
155
|
+
{ channel: key, archived },
|
|
156
|
+
"the conversation serves someone else now; its earlier history was archived",
|
|
157
|
+
);
|
|
158
|
+
this.#gates.delete(key);
|
|
159
|
+
this.#restored.delete(key);
|
|
160
|
+
await this.#options.confirmations.save(key, undefined);
|
|
161
|
+
}
|
|
162
|
+
return this.#factory.create(
|
|
109
163
|
agent?.home ?? channel,
|
|
110
|
-
|
|
164
|
+
history,
|
|
111
165
|
await this.gate(key, agent !== undefined),
|
|
112
166
|
this.slot(key),
|
|
113
167
|
ownerAttachmentDir(this.#options.dataDir, channel),
|
|
114
168
|
agent,
|
|
115
169
|
request?.kind ?? "owner",
|
|
116
|
-
|
|
170
|
+
conversation,
|
|
171
|
+
);
|
|
172
|
+
})();
|
|
117
173
|
pending.catch(() => this.#sessions.delete(key));
|
|
118
174
|
this.#sessions.set(key, pending);
|
|
119
175
|
}
|
|
120
176
|
return pending;
|
|
121
177
|
}
|
|
122
178
|
|
|
123
|
-
/**
|
|
179
|
+
/**
|
|
180
|
+
* Whom the conversation serves at this turn: as the turn names it, else as the host records it
|
|
181
|
+
* now, read at every turn so a record changed while the session is open counts; else as its open
|
|
182
|
+
* session was made for, or its history records, so a conversation nothing names carries on as it
|
|
183
|
+
* was, after a restart too; else shared.
|
|
184
|
+
*/
|
|
185
|
+
async conversation(
|
|
186
|
+
key: ChannelKey,
|
|
187
|
+
request: Pick<TurnRequest, "agent" | "conversation">,
|
|
188
|
+
): Promise<TurnConversation> {
|
|
189
|
+
const named = await namedConversation(key, request, this.#options);
|
|
190
|
+
if (named) return named;
|
|
191
|
+
const open = this.#sessions.get(key);
|
|
192
|
+
if (open) return (await open).conversation;
|
|
193
|
+
return (
|
|
194
|
+
historyScope(
|
|
195
|
+
this.#factory.sessionDir(key),
|
|
196
|
+
this.#factory.cwd(request.agent),
|
|
197
|
+
) ?? { visibility: "shared" }
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The conversation's session for the turn: rebuilt with its history when its session tools or
|
|
203
|
+
* skills changed, and made anew, with nothing held for it, when the conversation serves someone
|
|
204
|
+
* else than it was made for, its history archived rather than replayed.
|
|
205
|
+
*/
|
|
124
206
|
async freshSession(
|
|
125
207
|
key: ChannelKey,
|
|
126
208
|
request: TurnRequest,
|
|
127
209
|
): Promise<ChannelSession> {
|
|
128
210
|
const channelSession = await this.session(key, request);
|
|
211
|
+
if (
|
|
212
|
+
conversationChanged(channelSession.conversation, request.conversation)
|
|
213
|
+
) {
|
|
214
|
+
this.#options.logger.info(
|
|
215
|
+
{ channel: key },
|
|
216
|
+
"the conversation's scope changed; starting an isolated session",
|
|
217
|
+
);
|
|
218
|
+
await this.forget(key);
|
|
219
|
+
return this.session(key, request);
|
|
220
|
+
}
|
|
129
221
|
const stale = this.#staleReason(channelSession, request);
|
|
130
222
|
if (!stale) return channelSession;
|
|
131
223
|
this.#options.logger.info(
|
|
@@ -137,7 +229,7 @@ export class ConversationSessions {
|
|
|
137
229
|
return this.session(key, request);
|
|
138
230
|
}
|
|
139
231
|
|
|
140
|
-
/** Why a cached session must be rebuilt before this turn
|
|
232
|
+
/** Why a cached session must be rebuilt, keeping its history, before this turn; undefined when it is current. */
|
|
141
233
|
#staleReason(
|
|
142
234
|
channelSession: ChannelSession,
|
|
143
235
|
request: TurnRequest,
|
|
@@ -151,6 +243,25 @@ export class ConversationSessions {
|
|
|
151
243
|
return undefined;
|
|
152
244
|
}
|
|
153
245
|
|
|
246
|
+
/**
|
|
247
|
+
* The messages of a conversation's history: its open session's, or else read from its files
|
|
248
|
+
* without opening one, so reading them fixes no scope; none when they were recorded, or its open
|
|
249
|
+
* session was made, for someone else than the conversation serves now.
|
|
250
|
+
*/
|
|
251
|
+
async messages(key: ChannelKey): Promise<readonly AgentMessage[]> {
|
|
252
|
+
const now = await this.conversation(key, {});
|
|
253
|
+
const open = this.#sessions.get(key);
|
|
254
|
+
if (open) {
|
|
255
|
+
const { conversation, session } = await open;
|
|
256
|
+
return conversationChanged(conversation, now) ? [] : session.messages;
|
|
257
|
+
}
|
|
258
|
+
return historyMessages(
|
|
259
|
+
this.#factory.sessionDir(key),
|
|
260
|
+
this.#factory.cwd(undefined),
|
|
261
|
+
now,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
154
265
|
/** Drops the open session and everything held for the conversation, leaving its files. */
|
|
155
266
|
// pi-lens-ignore: mixed-async-styles — disposal failures of a dropped session are swallowed in a chain on purpose
|
|
156
267
|
async forget(channel: ChannelKey): Promise<void> {
|
|
@@ -160,6 +271,7 @@ export class ConversationSessions {
|
|
|
160
271
|
?.then(({ session }) => session.dispose())
|
|
161
272
|
.catch(() => undefined);
|
|
162
273
|
this.#gates.delete(channel);
|
|
274
|
+
this.#restored.delete(channel);
|
|
163
275
|
this.#slots.delete(channel);
|
|
164
276
|
this.usage.delete(channel);
|
|
165
277
|
this.judged.delete(channel);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type { OwnerAnswer, OwnerQuestion } from "../../domain/owner-prompts.ts";
|
|
4
3
|
import { messages } from "../../i18n/index.ts";
|
|
5
4
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
5
|
+
import type { OwnerAnswer, OwnerQuestion } from "../../interactions/prompts.ts";
|
|
6
6
|
import { toolText } from "../../shared/tool-result.ts";
|
|
7
7
|
import type { PromptSlot } from "../prompt-slot.ts";
|
|
8
8
|
|
|
@@ -3,10 +3,11 @@ import type {
|
|
|
3
3
|
HeldCall,
|
|
4
4
|
PendingConfirmation,
|
|
5
5
|
} from "../../domain/conversation.ts";
|
|
6
|
-
import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
|
|
7
6
|
import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
|
|
8
7
|
import { messages } from "../../i18n/index.ts";
|
|
9
8
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
9
|
+
import type { Prompts } from "../../interactions/prompts.ts";
|
|
10
|
+
import type { Speaker } from "../../speakers.ts";
|
|
10
11
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
11
12
|
import type { PromptSlot } from "../prompt-slot.ts";
|
|
12
13
|
|
|
@@ -16,6 +17,17 @@ const CARD_INPUT_CHARS = 1_500;
|
|
|
16
17
|
/** Held actions older than this cannot be approved any more. */
|
|
17
18
|
export const CONFIRMATION_TTL_MS = 24 * 3_600_000;
|
|
18
19
|
|
|
20
|
+
/** The held actions when they can still be approved at `now`; expired ones count as none. */
|
|
21
|
+
export function unexpired(
|
|
22
|
+
pending: PendingConfirmation | undefined,
|
|
23
|
+
now = new Date(),
|
|
24
|
+
): PendingConfirmation | undefined {
|
|
25
|
+
if (!pending) return undefined;
|
|
26
|
+
return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
|
|
27
|
+
? undefined
|
|
28
|
+
: pending;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
/** JSON with object keys sorted at every level, so equal inputs compare equal. */
|
|
20
32
|
// pi-lens-ignore: no-unknown-parameters — tool input is untyped model JSON; this is where it is read
|
|
21
33
|
export function canonicalJson(value: unknown): string {
|
|
@@ -55,7 +67,7 @@ function byCodeUnit(a: string, b: string): number {
|
|
|
55
67
|
export class ConfirmationGate {
|
|
56
68
|
#selectionId: string | undefined;
|
|
57
69
|
/** The running turn's speaker, whom the actions it holds belong to. */
|
|
58
|
-
#
|
|
70
|
+
#speaker: Pick<Speaker, "id" | "principalId"> | undefined;
|
|
59
71
|
#pending: PendingConfirmation | undefined;
|
|
60
72
|
#approved: HeldCall[] = [];
|
|
61
73
|
readonly #holds: HoldCheck;
|
|
@@ -86,25 +98,22 @@ export class ConfirmationGate {
|
|
|
86
98
|
|
|
87
99
|
/** Held actions awaiting the owner's answer; expired ones count as none. */
|
|
88
100
|
pending(now = new Date()): PendingConfirmation | undefined {
|
|
89
|
-
|
|
90
|
-
if (!pending) return undefined;
|
|
91
|
-
return now.getTime() - pending.heldAt.getTime() > CONFIRMATION_TTL_MS
|
|
92
|
-
? undefined
|
|
93
|
-
: pending;
|
|
101
|
+
return unexpired(this.#pending, now);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
/**
|
|
97
|
-
* `addressee` is who the turn is for; without one, the owner. `
|
|
98
|
-
* recorded with what it holds; without one, only the
|
|
105
|
+
* `addressee` is who the turn is for; without one, the owner. `speaker` is who spoke the turn,
|
|
106
|
+
* recorded with what it holds by their id and principal; without one, only the owners may
|
|
107
|
+
* approve them.
|
|
99
108
|
*/
|
|
100
109
|
beginTurn(
|
|
101
110
|
selectionId: string,
|
|
102
111
|
confirmed: boolean,
|
|
103
112
|
addressee?: OwnerIdentity,
|
|
104
|
-
|
|
113
|
+
speaker?: Pick<Speaker, "id" | "principalId">,
|
|
105
114
|
): void {
|
|
106
115
|
this.#selectionId = selectionId;
|
|
107
|
-
this.#
|
|
116
|
+
this.#speaker = speaker;
|
|
108
117
|
this.#addressee = addressee ?? this.#owner;
|
|
109
118
|
this.#approved = confirmed ? [...(this.pending()?.calls ?? [])] : [];
|
|
110
119
|
this.#pending = undefined;
|
|
@@ -127,7 +136,7 @@ export class ConfirmationGate {
|
|
|
127
136
|
async review(
|
|
128
137
|
tool: string,
|
|
129
138
|
input: Record<string, unknown>,
|
|
130
|
-
ask?: { prompts:
|
|
139
|
+
ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
|
|
131
140
|
): Promise<string | undefined> {
|
|
132
141
|
return (await this.decide(tool, input, ask)).reason;
|
|
133
142
|
}
|
|
@@ -139,7 +148,7 @@ export class ConfirmationGate {
|
|
|
139
148
|
async decide(
|
|
140
149
|
tool: string,
|
|
141
150
|
input: Record<string, unknown>,
|
|
142
|
-
ask?: { prompts:
|
|
151
|
+
ask?: { prompts: Prompts; asker: string; signal?: AbortSignal },
|
|
143
152
|
): Promise<{ reason?: string; approvedOnCard?: true }> {
|
|
144
153
|
const call = this.#needing(tool, input);
|
|
145
154
|
if (!call) return {};
|
|
@@ -191,7 +200,12 @@ export class ConfirmationGate {
|
|
|
191
200
|
selectionId,
|
|
192
201
|
heldAt: new Date(),
|
|
193
202
|
calls: [],
|
|
194
|
-
...(this.#
|
|
203
|
+
...(this.#speaker
|
|
204
|
+
? {
|
|
205
|
+
speakerId: this.#speaker.id,
|
|
206
|
+
principalId: this.#speaker.principalId,
|
|
207
|
+
}
|
|
208
|
+
: {}),
|
|
195
209
|
};
|
|
196
210
|
if (
|
|
197
211
|
!pending.calls.some((c) => c.tool === call.tool && c.input === call.input)
|