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
|
@@ -7,12 +7,24 @@ import type {
|
|
|
7
7
|
} from "../contract/runtime.ts";
|
|
8
8
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
9
9
|
import type { InterimTextMode } from "../domain/interim.ts";
|
|
10
|
+
import type { IdentityService } from "../identity/identity-service.ts";
|
|
10
11
|
import type { OwnerIdentity } from "../identity.ts";
|
|
12
|
+
import type { PromptScope } from "../interactions/prompts.ts";
|
|
11
13
|
import { AGENT_BRIEF, EffortJudge } from "../judging/effort-judge.ts";
|
|
12
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
formatModelRef,
|
|
16
|
+
type ModelRef,
|
|
17
|
+
type ThinkingLevel,
|
|
18
|
+
} from "../models.ts";
|
|
13
19
|
import type { PluginContext, RoundtablePlugin } from "../plugin.ts";
|
|
14
|
-
import { RUNTIME } from "../services.ts";
|
|
20
|
+
import { CONVERSATIONS, IDENTITY, RUNTIME } from "../services.ts";
|
|
15
21
|
import type { Speaker } from "../speakers.ts";
|
|
22
|
+
import {
|
|
23
|
+
BRIDGE_MEMORY_FIX,
|
|
24
|
+
bridgeMemoryProblem,
|
|
25
|
+
liveBridgeRefusal,
|
|
26
|
+
onClaudeBridge,
|
|
27
|
+
} from "./bridge-guard.ts";
|
|
16
28
|
import { PendingConfirmationStore } from "./pending-confirmation-store.ts";
|
|
17
29
|
import { PiAgentRuntime } from "./pi-agent-runtime.ts";
|
|
18
30
|
|
|
@@ -63,6 +75,10 @@ export interface RuntimePluginOptions {
|
|
|
63
75
|
interimText?: InterimTextMode;
|
|
64
76
|
/** An intermediate text this long or longer is posted as an ordinary message; default 400. */
|
|
65
77
|
interimPrimaryChars?: number;
|
|
78
|
+
/** Whether the host keeps each person's memory; bridge turns then check private history. */
|
|
79
|
+
memory?: boolean;
|
|
80
|
+
/** Why the configuration lets several people speak in a shared conversation, as `configuredCrowd` says. */
|
|
81
|
+
crowd?: string;
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/**
|
|
@@ -79,18 +95,36 @@ export function runtimePlugin(
|
|
|
79
95
|
PendingConfirmationStore.attach(context.database()),
|
|
80
96
|
): RoundtablePlugin {
|
|
81
97
|
let runtime: AgentRuntime | undefined;
|
|
98
|
+
let identity: IdentityService | undefined;
|
|
99
|
+
let warn: ((message: string) => void) | undefined;
|
|
100
|
+
// Admission is a startup risk warning, not proof that this conversation holds private history.
|
|
101
|
+
const bridgeRefusal = liveBridgeRefusal({
|
|
102
|
+
memory: options.memory,
|
|
103
|
+
crowd: options.crowd,
|
|
104
|
+
identity: () => identity,
|
|
105
|
+
primary: options.owner.id,
|
|
106
|
+
});
|
|
82
107
|
return {
|
|
83
108
|
name: RUNTIME_PLUGIN,
|
|
84
109
|
migrations: PendingConfirmationStore.migrations(),
|
|
85
110
|
provides: [RUNTIME],
|
|
86
111
|
preflight: async () => {
|
|
112
|
+
if (onClaudeBridge(options.model)) {
|
|
113
|
+
const why = await bridgeRefusal();
|
|
114
|
+
if (why)
|
|
115
|
+
warn?.(
|
|
116
|
+
`${bridgeMemoryProblem(formatModelRef(options.model), why)} ${BRIDGE_MEMORY_FIX}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
87
119
|
await runtime?.preflight?.();
|
|
88
120
|
},
|
|
89
121
|
setup: async (context) => {
|
|
90
|
-
const { logger, providers } = context;
|
|
122
|
+
const { logger, providers, services } = context;
|
|
123
|
+
identity = services.find(IDENTITY);
|
|
124
|
+
warn = (message) => logger.warn({}, message);
|
|
91
125
|
const heldActions = await openHeldActions(context);
|
|
92
|
-
const prompts = (channel: ChannelKey,
|
|
93
|
-
context.surfaces.prompts(channel,
|
|
126
|
+
const prompts = (channel: ChannelKey, scope?: PromptScope | Speaker) =>
|
|
127
|
+
context.surfaces.prompts(channel, scope);
|
|
94
128
|
const agents = () => options.agents?.current();
|
|
95
129
|
const running: AgentRuntime = providers.filled.has("runtime")
|
|
96
130
|
? providers.runtime(
|
|
@@ -108,6 +142,17 @@ export function runtimePlugin(
|
|
|
108
142
|
)
|
|
109
143
|
: new PiAgentRuntime({
|
|
110
144
|
owner: options.owner,
|
|
145
|
+
// Read at every turn that names no conversation, after every plugin set up.
|
|
146
|
+
conversationOf: async (key) => {
|
|
147
|
+
const record = await services.find(CONVERSATIONS)?.get(key);
|
|
148
|
+
if (!record) return undefined;
|
|
149
|
+
return record.visibility === "private" &&
|
|
150
|
+
record.principalId !== undefined
|
|
151
|
+
? { visibility: "private", principalId: record.principalId }
|
|
152
|
+
: { visibility: "shared" };
|
|
153
|
+
},
|
|
154
|
+
principalOf: async (id) => services.find(IDENTITY)?.principal(id),
|
|
155
|
+
memory: options.memory === true,
|
|
111
156
|
sessions: context.sessions,
|
|
112
157
|
agentDir: options.agentDir,
|
|
113
158
|
modelRuntime: options.modelRuntime,
|
|
@@ -7,20 +7,24 @@ import type {
|
|
|
7
7
|
import type { AgentSessions, LoadedSkill } from "../contract/runtime.ts";
|
|
8
8
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
9
9
|
import type { InterimTextMode } from "../domain/interim.ts";
|
|
10
|
-
import type {
|
|
10
|
+
import type { TurnConversation } from "../domain/ports.ts";
|
|
11
|
+
import type { Principal } from "../identity/principal-store.ts";
|
|
11
12
|
import type { OwnerIdentity } from "../identity.ts";
|
|
13
|
+
import type { PromptScope, Prompts } from "../interactions/prompts.ts";
|
|
12
14
|
import type { Logger } from "../log.ts";
|
|
13
15
|
import type { ThinkingLevel, ThinkingPicker } from "../models.ts";
|
|
14
16
|
import type { LinkedSessions } from "../plugin.ts";
|
|
15
17
|
import {
|
|
16
18
|
planOrder,
|
|
17
19
|
type SessionContext,
|
|
20
|
+
type SessionConversation,
|
|
18
21
|
type SessionPlan,
|
|
19
22
|
type SessionTool,
|
|
20
23
|
} from "../sessions.ts";
|
|
21
|
-
import type { Speaker } from "../speakers.ts";
|
|
22
24
|
import type { ToolTiers } from "../tool-tiers.ts";
|
|
25
|
+
import type { MemoryDraws } from "./extensions/private-memory.ts";
|
|
23
26
|
import type { PendingConfirmationStore } from "./pending-confirmation-store.ts";
|
|
27
|
+
import { scopedCalls } from "./tool-call-scope.ts";
|
|
24
28
|
|
|
25
29
|
export type TurnMessage = Extract<
|
|
26
30
|
AgentSessionEvent,
|
|
@@ -33,8 +37,19 @@ export type CompactionEnd = Extract<
|
|
|
33
37
|
export const TRANSCRIPT_ENTRY_CHARS = 1000;
|
|
34
38
|
|
|
35
39
|
export interface PiAgentRuntimeOptions {
|
|
36
|
-
/**
|
|
37
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The primary owner, by their principal id: their conversations' prompts and tool results name
|
|
42
|
+
* them as configured, and the host's own turns address them.
|
|
43
|
+
*/
|
|
44
|
+
owner: OwnerIdentity & { id: string };
|
|
45
|
+
/**
|
|
46
|
+
* The host's record of a conversation, read at every turn that names none, so a record changed
|
|
47
|
+
* while its session is open counts, and when its transcript is read; without it, or without a
|
|
48
|
+
* record, the conversation is as its open session or its history was made, else shared.
|
|
49
|
+
*/
|
|
50
|
+
conversationOf?: (key: ChannelKey) => Promise<TurnConversation | undefined>;
|
|
51
|
+
/** A principal's name and pronouns, which a private conversation's tool descriptions use. */
|
|
52
|
+
principalOf?: (id: string) => Promise<Principal | undefined>;
|
|
38
53
|
agentDir: string;
|
|
39
54
|
/** Shared by the host's sessions, so logins refresh in one place. */
|
|
40
55
|
modelRuntime: ModelRuntime;
|
|
@@ -56,13 +71,11 @@ export interface PiAgentRuntimeOptions {
|
|
|
56
71
|
/** The agent server's sessions: their shared workspace, model, and skills. */
|
|
57
72
|
agents?: AgentSessions;
|
|
58
73
|
/**
|
|
59
|
-
*
|
|
60
|
-
* cannot show them. Without it every
|
|
74
|
+
* The prompts of a channel for an interactive turn's scope, made from its speaker and its
|
|
75
|
+
* conversation's visibility; undefined for a channel that cannot show them. Without it every
|
|
76
|
+
* held action waits for a message that approves it.
|
|
61
77
|
*/
|
|
62
|
-
prompts?: (
|
|
63
|
-
channel: ChannelKey,
|
|
64
|
-
speaker?: Speaker,
|
|
65
|
-
) => OwnerPrompts | undefined;
|
|
78
|
+
prompts?: (channel: ChannelKey, scope?: PromptScope) => Prompts | undefined;
|
|
66
79
|
/** The lowest tier that may use each tool; the default names the core's tools and leaves the rest to the owner. */
|
|
67
80
|
toolTiers?: ToolTiers;
|
|
68
81
|
/** A run that takes longer, not counting time spent waiting on the owner's cards, is aborted and reported as failed. */
|
|
@@ -76,6 +89,8 @@ export interface PiAgentRuntimeOptions {
|
|
|
76
89
|
interimPrimaryChars?: number;
|
|
77
90
|
/** How long a new session waits for its MCP tools to register. */
|
|
78
91
|
mcpConnectTimeoutMs?: number;
|
|
92
|
+
/** Defaults to true: bridge shared turns refuse raw private exchanges inaccessible to the reader. */
|
|
93
|
+
memory?: boolean;
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
export type { AgentSessions, LoadedSkill };
|
|
@@ -91,12 +106,14 @@ export interface CoreExtensions {
|
|
|
91
106
|
confirmationGate: ExtensionFactory;
|
|
92
107
|
askUser: ExtensionFactory;
|
|
93
108
|
selfCompactGuard: ExtensionFactory;
|
|
109
|
+
privateMemory: ExtensionFactory;
|
|
94
110
|
activeTools: ExtensionFactory;
|
|
95
111
|
}
|
|
96
112
|
|
|
97
113
|
/**
|
|
98
114
|
* A session's extensions in load order: the tools phase, the core's attachment, gate, ask-user,
|
|
99
|
-
* and compact guard, the compactor, the MCP phase,
|
|
115
|
+
* and compact guard, the compactor, the MCP phase, the memory projection, so each request is
|
|
116
|
+
* projected after every other extension changed it, and active-tools last, so its
|
|
100
117
|
* before_agent_start handler runs after every other extension's.
|
|
101
118
|
*/
|
|
102
119
|
export function sessionExtensions(
|
|
@@ -107,7 +124,9 @@ export function sessionExtensions(
|
|
|
107
124
|
const contributed = (tools: readonly SessionTool[]) =>
|
|
108
125
|
tools.flatMap((tool) => {
|
|
109
126
|
const factory = tool.snapshot().factory(session);
|
|
110
|
-
return factory
|
|
127
|
+
return factory
|
|
128
|
+
? [{ name: tool.name, factory: scopedCalls(factory) }]
|
|
129
|
+
: [];
|
|
111
130
|
});
|
|
112
131
|
return [
|
|
113
132
|
...contributed(plan.tools),
|
|
@@ -117,6 +136,7 @@ export function sessionExtensions(
|
|
|
117
136
|
{ name: "self-compact-guard", factory: core.selfCompactGuard },
|
|
118
137
|
...contributed(plan.compaction ? [plan.compaction] : []),
|
|
119
138
|
...contributed(plan.mcp),
|
|
139
|
+
{ name: "private-memory", factory: core.privateMemory },
|
|
120
140
|
{ name: "active-tools", factory: core.activeTools },
|
|
121
141
|
];
|
|
122
142
|
}
|
|
@@ -129,4 +149,12 @@ export interface ChannelSession {
|
|
|
129
149
|
revisions: string;
|
|
130
150
|
/** The skills an agent session was built with, as skillsKey. */
|
|
131
151
|
skills: string;
|
|
152
|
+
/** Whom the session's conversation serves, fixed when it was built. */
|
|
153
|
+
conversation: SessionConversation;
|
|
154
|
+
/** Whom its tool descriptions address. */
|
|
155
|
+
addressee: OwnerIdentity;
|
|
156
|
+
/** Whose memory its turns read, as resolved when it was built; a worker beside it keeps the same. */
|
|
157
|
+
memory: SessionContext["memory"];
|
|
158
|
+
/** Its tool calls running now, which a task that reads the reader's memory marks as drawing on it. */
|
|
159
|
+
draws: MemoryDraws;
|
|
132
160
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
2
|
+
import type { TurnConversation, TurnRequest } from "../domain/ports.ts";
|
|
3
|
+
import {
|
|
4
|
+
type Principal,
|
|
5
|
+
SYSTEM_PRINCIPAL,
|
|
6
|
+
} from "../identity/principal-store.ts";
|
|
7
|
+
import { addresseeOf, type OwnerIdentity } from "../identity.ts";
|
|
8
|
+
import type { SessionConversation } from "../sessions.ts";
|
|
9
|
+
import { addressee, type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
10
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whom a conversation serves as something says so: an agent's is shared; another is as the turn
|
|
14
|
+
* says, else as the host's record says; undefined when neither does.
|
|
15
|
+
*/
|
|
16
|
+
export async function namedConversation(
|
|
17
|
+
key: ChannelKey,
|
|
18
|
+
request: Pick<TurnRequest, "agent" | "conversation"> | undefined,
|
|
19
|
+
options: Pick<PiAgentRuntimeOptions, "conversationOf">,
|
|
20
|
+
): Promise<TurnConversation | undefined> {
|
|
21
|
+
if (request?.agent) return { visibility: "shared" };
|
|
22
|
+
return request?.conversation ?? (await options.conversationOf?.(key));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whom a new session's conversation serves: as the turn or the host's record names it, else
|
|
27
|
+
* shared, never the owner's by default. A private one's person is looked up for their name and
|
|
28
|
+
* pronouns.
|
|
29
|
+
*/
|
|
30
|
+
export async function sessionConversation(
|
|
31
|
+
key: ChannelKey,
|
|
32
|
+
request: Pick<TurnRequest, "agent" | "conversation"> | undefined,
|
|
33
|
+
options: Pick<PiAgentRuntimeOptions, "conversationOf" | "principalOf">,
|
|
34
|
+
): Promise<SessionConversation> {
|
|
35
|
+
const given = await namedConversation(key, request, options);
|
|
36
|
+
if (given?.visibility !== "private") return { visibility: "shared" };
|
|
37
|
+
const principal = await options.principalOf?.(given.principalId);
|
|
38
|
+
return {
|
|
39
|
+
visibility: "private",
|
|
40
|
+
principalId: given.principalId,
|
|
41
|
+
...(principal ? { principal } : {}),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the turn names its conversation otherwise than the session was made for, so it is rebuilt. */
|
|
46
|
+
export function conversationChanged(
|
|
47
|
+
session: SessionConversation,
|
|
48
|
+
given: TurnConversation | undefined,
|
|
49
|
+
): boolean {
|
|
50
|
+
if (!given) return false;
|
|
51
|
+
if (given.visibility !== session.visibility) return true;
|
|
52
|
+
return (
|
|
53
|
+
given.visibility === "private" &&
|
|
54
|
+
session.visibility === "private" &&
|
|
55
|
+
given.principalId !== session.principalId
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Whom a session's tool descriptions address: the primary owner as configured, so their own
|
|
61
|
+
* conversations read as in 0.8; another private conversation's person by their name and
|
|
62
|
+
* pronouns; and the speaker, unnamed, in a shared one or a private one of someone the host has
|
|
63
|
+
* no name for.
|
|
64
|
+
*/
|
|
65
|
+
export function sessionAddressee(
|
|
66
|
+
conversation: SessionConversation,
|
|
67
|
+
owner: OwnerIdentity & { id: string },
|
|
68
|
+
): OwnerIdentity {
|
|
69
|
+
if (conversation.visibility !== "private") return THE_SPEAKER;
|
|
70
|
+
if (conversation.principalId === owner.id) return owner;
|
|
71
|
+
const named = namedPrincipal(conversation);
|
|
72
|
+
return named ? addresseeOf(named) : THE_SPEAKER;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A private conversation's person, when the host knows them by a name: one carried over from 0.8
|
|
77
|
+
* may be named by their id alone until someone names them, and an id is no name to address.
|
|
78
|
+
*/
|
|
79
|
+
export function namedPrincipal(
|
|
80
|
+
conversation: SessionConversation,
|
|
81
|
+
): Principal | undefined {
|
|
82
|
+
if (conversation.visibility !== "private") return undefined;
|
|
83
|
+
const { principal } = conversation;
|
|
84
|
+
return principal && principal.displayName !== principal.id
|
|
85
|
+
? principal
|
|
86
|
+
: undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whom a turn's approvals and answers name: a private conversation's person as its session
|
|
91
|
+
* addresses them; the host's own turns and the primary owner as the owner; anyone else, a
|
|
92
|
+
* second owner too, by their name.
|
|
93
|
+
*/
|
|
94
|
+
export function turnAddressee(
|
|
95
|
+
speaker: Speaker | undefined,
|
|
96
|
+
session: { conversation: SessionConversation; addressee: OwnerIdentity },
|
|
97
|
+
owner: OwnerIdentity & { id: string },
|
|
98
|
+
): OwnerIdentity {
|
|
99
|
+
const { conversation } = session;
|
|
100
|
+
if (
|
|
101
|
+
speaker &&
|
|
102
|
+
conversation.visibility === "private" &&
|
|
103
|
+
speaker.principalId === conversation.principalId &&
|
|
104
|
+
session.addressee !== THE_SPEAKER
|
|
105
|
+
)
|
|
106
|
+
return session.addressee;
|
|
107
|
+
const own =
|
|
108
|
+
!speaker ||
|
|
109
|
+
speaker.principalId === owner.id ||
|
|
110
|
+
speaker.principalId === SYSTEM_PRINCIPAL;
|
|
111
|
+
return own ? owner : addressee({ ...speaker, tier: "member" }, owner);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whose memory a session's running turn reads: a private conversation's person's, whoever speaks
|
|
116
|
+
* in it, and in a shared one the speaker's; no one's for the host's own turns there, nor while
|
|
117
|
+
* no turn runs.
|
|
118
|
+
*/
|
|
119
|
+
export function memoryReader(
|
|
120
|
+
conversation: SessionConversation,
|
|
121
|
+
speaker: Speaker | undefined,
|
|
122
|
+
): string | undefined {
|
|
123
|
+
if (conversation.visibility === "private") return conversation.principalId;
|
|
124
|
+
if (!speaker || speaker.principalId === SYSTEM_PRINCIPAL) return undefined;
|
|
125
|
+
return speaker.principalId;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Why a turn may not run in the session's conversation: someone other than its person speaking
|
|
130
|
+
* in a private one. The host's own turns may.
|
|
131
|
+
*/
|
|
132
|
+
export function refusedSpeaker(
|
|
133
|
+
conversation: SessionConversation,
|
|
134
|
+
speaker: Speaker,
|
|
135
|
+
): string | undefined {
|
|
136
|
+
if (conversation.visibility !== "private") return undefined;
|
|
137
|
+
if (
|
|
138
|
+
speaker.principalId === conversation.principalId ||
|
|
139
|
+
speaker.principalId === SYSTEM_PRINCIPAL
|
|
140
|
+
)
|
|
141
|
+
return undefined;
|
|
142
|
+
return `the conversation is private to ${conversation.principalId}, so a turn of ${speaker.principalId} may not run in it`;
|
|
143
|
+
}
|
|
@@ -17,6 +17,7 @@ import type { LinkedSessions } from "../plugin.ts";
|
|
|
17
17
|
import {
|
|
18
18
|
planOrder,
|
|
19
19
|
type SessionContext,
|
|
20
|
+
type SessionConversation,
|
|
20
21
|
type SessionPlan,
|
|
21
22
|
type ToolSelection,
|
|
22
23
|
type TransientTask,
|
|
@@ -24,7 +25,8 @@ import {
|
|
|
24
25
|
import { activeToolsExtension } from "../shared/active-tools.ts";
|
|
25
26
|
import { packageDir } from "../shared/package-dir.ts";
|
|
26
27
|
import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
|
|
27
|
-
import {
|
|
28
|
+
import { type Speaker, THE_SPEAKER } from "../speakers.ts";
|
|
29
|
+
import { currentToolNames } from "../tool-tiers.ts";
|
|
28
30
|
import {
|
|
29
31
|
CompactionTiers,
|
|
30
32
|
compactionEngine,
|
|
@@ -35,6 +37,11 @@ import {
|
|
|
35
37
|
type ConfirmationGate,
|
|
36
38
|
confirmationGateExtension,
|
|
37
39
|
} from "./extensions/confirmation-gate.ts";
|
|
40
|
+
import {
|
|
41
|
+
MemoryDraws,
|
|
42
|
+
privateCompaction,
|
|
43
|
+
privateMemoryExtension,
|
|
44
|
+
} from "./extensions/private-memory.ts";
|
|
38
45
|
import {
|
|
39
46
|
COMPACT_TOOL,
|
|
40
47
|
selfCompactGuardExtension,
|
|
@@ -51,13 +58,26 @@ import {
|
|
|
51
58
|
sessionExtensions,
|
|
52
59
|
skillsKey,
|
|
53
60
|
} from "./runtime-types.ts";
|
|
61
|
+
import {
|
|
62
|
+
memoryReader,
|
|
63
|
+
sessionAddressee,
|
|
64
|
+
turnAddressee,
|
|
65
|
+
} from "./session-conversation.ts";
|
|
54
66
|
|
|
55
67
|
/** What a session factory asks of the runtime that owns the turns. */
|
|
56
68
|
export interface SessionFactoryDeps {
|
|
57
69
|
/** The person the conversation's running turn is for. */
|
|
58
70
|
speaker(channel: ChannelKey): Speaker | undefined;
|
|
59
|
-
/**
|
|
60
|
-
|
|
71
|
+
/** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
|
|
72
|
+
withholdsMemory(channel: ChannelKey): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Runs a task beside the conversation of `home`, under its gate, at the tier of the running
|
|
75
|
+
* turn of `turn`: the conversation's own channel, or an agent's seat in a group.
|
|
76
|
+
*/
|
|
77
|
+
runTask(
|
|
78
|
+
scope: { turn: ChannelKey; home: ChannelKey },
|
|
79
|
+
task: TransientTask,
|
|
80
|
+
): Promise<string>;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
83
|
/** Builds Pi sessions for the runtime: one resource loader per session, its extensions in order. */
|
|
@@ -107,7 +127,7 @@ export class SessionFactory {
|
|
|
107
127
|
(tool) => tool.snapshot().groups ?? [],
|
|
108
128
|
);
|
|
109
129
|
return [
|
|
110
|
-
...selection.tools,
|
|
130
|
+
...currentToolNames(selection.tools, this.#options.logger),
|
|
111
131
|
COMPACT_TOOL,
|
|
112
132
|
ASK_USER_TOOL,
|
|
113
133
|
...groups
|
|
@@ -116,6 +136,30 @@ export class SessionFactory {
|
|
|
116
136
|
];
|
|
117
137
|
}
|
|
118
138
|
|
|
139
|
+
/** The tools of a task beside a turn: its selection without what it excludes, and it never asks. */
|
|
140
|
+
taskTools(selection: ToolSelection, exclude: readonly string[]): string[] {
|
|
141
|
+
const excluded = new Set([
|
|
142
|
+
...currentToolNames(exclude, this.#options.logger),
|
|
143
|
+
ASK_USER_TOOL,
|
|
144
|
+
]);
|
|
145
|
+
return this.toolsFor(selection).filter((name) => !excluded.has(name));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every tool startup refuses to run without: the plugins', the core's, and the session tools'. */
|
|
149
|
+
requiredTools(): string[] {
|
|
150
|
+
return currentToolNames(
|
|
151
|
+
[
|
|
152
|
+
...this.link().requiredTools,
|
|
153
|
+
COMPACT_TOOL,
|
|
154
|
+
ASK_USER_TOOL,
|
|
155
|
+
...planOrder(this.plan).flatMap(
|
|
156
|
+
(tool) => tool.snapshot().requiredTools ?? [],
|
|
157
|
+
),
|
|
158
|
+
],
|
|
159
|
+
this.#options.logger,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
119
163
|
/** Waits for the tools the session tools register late, such as pi-mcp-adapter's after its eager connection. */
|
|
120
164
|
async #awaitTools(
|
|
121
165
|
session: AgentSession,
|
|
@@ -212,15 +256,41 @@ export class SessionFactory {
|
|
|
212
256
|
attachmentDir: string,
|
|
213
257
|
agent: AgentTurnScope | undefined,
|
|
214
258
|
kind: string,
|
|
259
|
+
/** Whom the conversation serves, fixed for the session's life. */
|
|
260
|
+
conversation: SessionConversation,
|
|
261
|
+
/**
|
|
262
|
+
* A worker's: the conversation whose running turn it acts in, and that conversation's memory
|
|
263
|
+
* policy, which it keeps rather than reading its own kind's.
|
|
264
|
+
*/
|
|
265
|
+
worker?: { turn: ChannelKey; memory: SessionContext["memory"] },
|
|
215
266
|
): Promise<ChannelSession> {
|
|
216
267
|
const { agentDir, model, thinking, logger } = this.#options;
|
|
217
268
|
// An agent's prompt is set before each run; the other kinds are refused here, before a session is built.
|
|
218
269
|
const persona = agent ? undefined : this.personaOf(kind);
|
|
219
270
|
const skills = this.skillsOf(agent);
|
|
271
|
+
const addressee = agent
|
|
272
|
+
? THE_SPEAKER
|
|
273
|
+
: sessionAddressee(conversation, this.#options.owner);
|
|
274
|
+
// An agent's conversations read each speaker's memory; another kind's, as its persona says;
|
|
275
|
+
// a worker's, as the conversation it works for.
|
|
276
|
+
const memory =
|
|
277
|
+
worker?.memory ??
|
|
278
|
+
(agent ? "speaker" : (this.link().personaMemory?.(kind) ?? "speaker"));
|
|
279
|
+
// The running turn is the conversation's: an agent's seat in a group, or its own channel; a
|
|
280
|
+
// worker's is the turn that started it.
|
|
281
|
+
const turnKey = worker?.turn ?? agent?.session ?? channel;
|
|
282
|
+
const deps = this.#deps;
|
|
220
283
|
const state = {
|
|
221
284
|
tools: [] as readonly string[],
|
|
222
285
|
revisions: revisionsKey(this.plan),
|
|
223
286
|
skills: skillsKey(skills),
|
|
287
|
+
conversation,
|
|
288
|
+
addressee,
|
|
289
|
+
// A turn the agent server keeps out of memory reads none, and so does a worker beside it.
|
|
290
|
+
get memory(): SessionContext["memory"] {
|
|
291
|
+
return deps.withholdsMemory(turnKey) ? "none" : memory;
|
|
292
|
+
},
|
|
293
|
+
draws: new MemoryDraws(),
|
|
224
294
|
};
|
|
225
295
|
const awaited = planOrder(this.plan).flatMap(
|
|
226
296
|
(tool) => tool.snapshot().awaitTools ?? [],
|
|
@@ -240,16 +310,34 @@ export class SessionFactory {
|
|
|
240
310
|
homeChannel: channel,
|
|
241
311
|
turnChannel: agent && agents ? agents.turnChannel(agent) : channel,
|
|
242
312
|
compaction: {
|
|
313
|
+
// A shared conversation's compactor summarizes no one's private memory.
|
|
243
314
|
wrap: (compactor) =>
|
|
244
|
-
tiers.wrapCompactor(
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
315
|
+
tiers.wrapCompactor(
|
|
316
|
+
compactor,
|
|
317
|
+
(bypass) =>
|
|
318
|
+
logger.info(
|
|
319
|
+
{ channel, ...bypass, ceiling: HARD_COMPACT_TOKENS },
|
|
320
|
+
"compaction skips the extension for Pi's summary",
|
|
321
|
+
),
|
|
322
|
+
conversation.visibility === "shared"
|
|
323
|
+
? (event) =>
|
|
324
|
+
privateCompaction(
|
|
325
|
+
event.preparation,
|
|
326
|
+
event.branchEntries.flatMap((entry) =>
|
|
327
|
+
entry.type === "message" ? [entry.message] : [],
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
: undefined,
|
|
249
331
|
),
|
|
250
332
|
},
|
|
251
|
-
|
|
252
|
-
|
|
333
|
+
conversation,
|
|
334
|
+
addressee,
|
|
335
|
+
get memory() {
|
|
336
|
+
return state.memory;
|
|
337
|
+
},
|
|
338
|
+
speaker: () => this.#deps.speaker(turnKey),
|
|
339
|
+
runTask: (task) =>
|
|
340
|
+
this.#deps.runTask({ turn: turnKey, home: channel }, task),
|
|
253
341
|
};
|
|
254
342
|
if (agent) context.agent = agent;
|
|
255
343
|
const resourceLoader = new DefaultResourceLoader({
|
|
@@ -265,12 +353,23 @@ export class SessionFactory {
|
|
|
265
353
|
extensionFactories: sessionExtensions(this.plan, context, {
|
|
266
354
|
readAttachment: readAttachmentExtension(attachmentDir),
|
|
267
355
|
confirmationGate: confirmationGateExtension(gate, slot),
|
|
268
|
-
askUser: askUserExtension(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
356
|
+
askUser: askUserExtension(slot, addressee, () =>
|
|
357
|
+
turnAddressee(
|
|
358
|
+
this.#deps.speaker(turnKey),
|
|
359
|
+
state,
|
|
360
|
+
this.#options.owner,
|
|
361
|
+
),
|
|
272
362
|
),
|
|
273
363
|
selfCompactGuard: selfCompactGuardExtension(),
|
|
364
|
+
// Each request carries no one's memory but the running turn's reader's.
|
|
365
|
+
privateMemory: privateMemoryExtension(
|
|
366
|
+
conversation.visibility === "shared",
|
|
367
|
+
() =>
|
|
368
|
+
deps.withholdsMemory(turnKey)
|
|
369
|
+
? undefined
|
|
370
|
+
: memoryReader(conversation, deps.speaker(turnKey)),
|
|
371
|
+
state.draws,
|
|
372
|
+
),
|
|
274
373
|
activeTools: activeToolsExtension(() => state.tools),
|
|
275
374
|
}),
|
|
276
375
|
// An agent's prompt is set before each run by the agent-prompt extension.
|
|
@@ -314,6 +413,7 @@ export class SessionFactory {
|
|
|
314
413
|
|
|
315
414
|
/** Pi's bash tool with TMPDIR set to `scratchDir`, so `mktemp` and tools write there. */
|
|
316
415
|
export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
|
|
416
|
+
// SAFETY: Pi's bash definition is a ToolDefinition typed by its own parameter schema, which the session takes as any custom tool.
|
|
317
417
|
return createBashToolDefinition(cwd, {
|
|
318
418
|
spawnHook: (context) => ({
|
|
319
419
|
...context,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { TurnConversation } from "../domain/ports.ts";
|
|
4
|
+
import { archiveSessions } from "./session-archive.ts";
|
|
5
|
+
|
|
6
|
+
/** The custom entry a session's history records whom it served in. */
|
|
7
|
+
export const SCOPE_ENTRY = "roundtable-conversation";
|
|
8
|
+
|
|
9
|
+
/** Whom a conversation serves, as a session's history records it. */
|
|
10
|
+
type Scope = Pick<TurnConversation, "visibility"> & { principalId?: string };
|
|
11
|
+
|
|
12
|
+
function scopeOf(conversation: Scope): TurnConversation {
|
|
13
|
+
return conversation.visibility === "private" &&
|
|
14
|
+
conversation.principalId !== undefined
|
|
15
|
+
? { visibility: "private", principalId: conversation.principalId }
|
|
16
|
+
: { visibility: "shared" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether two scopes are the same: shared both, or private to the same principal. */
|
|
20
|
+
export function sameScope(a: Scope, b: Scope): boolean {
|
|
21
|
+
return JSON.stringify(scopeOf(a)) === JSON.stringify(scopeOf(b));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The scope the history last recorded, or undefined when it records none, as before 0.9. */
|
|
25
|
+
function recordedScope(manager: SessionManager): TurnConversation | undefined {
|
|
26
|
+
const entry = manager
|
|
27
|
+
.getEntries()
|
|
28
|
+
.findLast(
|
|
29
|
+
(entry) => entry.type === "custom" && entry.customType === SCOPE_ENTRY,
|
|
30
|
+
);
|
|
31
|
+
return entry?.type === "custom"
|
|
32
|
+
? scopeOf(entry.data as TurnConversation)
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The latest history in `dir` for a session of the scope. A history recorded in another scope is
|
|
38
|
+
* archived, so the session starts with none: one person's conversation never replays for someone
|
|
39
|
+
* else, nor a private one in a shared one or the other way. A history that records no scope, as
|
|
40
|
+
* one made before 0.9, carries on. The history the session continues records its scope.
|
|
41
|
+
*/
|
|
42
|
+
export function scopedHistory(
|
|
43
|
+
dir: string,
|
|
44
|
+
cwd: string,
|
|
45
|
+
scope: Scope,
|
|
46
|
+
): { history: SessionManager; archived: number } {
|
|
47
|
+
let history = SessionManager.continueRecent(cwd, dir);
|
|
48
|
+
const recorded = recordedScope(history);
|
|
49
|
+
let archived = 0;
|
|
50
|
+
if (recorded && !sameScope(recorded, scope)) {
|
|
51
|
+
archived = archiveSessions(dir);
|
|
52
|
+
history = SessionManager.continueRecent(cwd, dir);
|
|
53
|
+
}
|
|
54
|
+
if (!recorded || archived > 0)
|
|
55
|
+
history.appendCustomEntry(SCOPE_ENTRY, scopeOf(scope));
|
|
56
|
+
return { history, archived };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The scope the latest history in `dir` records, if any. */
|
|
60
|
+
export function historyScope(
|
|
61
|
+
dir: string,
|
|
62
|
+
cwd: string,
|
|
63
|
+
): TurnConversation | undefined {
|
|
64
|
+
if (!existsSync(dir)) return undefined;
|
|
65
|
+
return recordedScope(SessionManager.continueRecent(cwd, dir));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The messages of the latest history in `dir`, read without opening a session, so reading them
|
|
70
|
+
* fixes no scope; none when it was recorded in another scope than `scope`.
|
|
71
|
+
*/
|
|
72
|
+
export function historyMessages(dir: string, cwd: string, scope: Scope) {
|
|
73
|
+
if (!existsSync(dir)) return [];
|
|
74
|
+
const history = SessionManager.continueRecent(cwd, dir);
|
|
75
|
+
const recorded = recordedScope(history);
|
|
76
|
+
if (recorded && !sameScope(recorded, scope)) return [];
|
|
77
|
+
return history.buildSessionContext().messages;
|
|
78
|
+
}
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TurnAttachments } from "../domain/attachment.ts";
|
|
2
3
|
|
|
3
4
|
export type PromptImages = NonNullable<Parameters<AgentSession["steer"]>[1]>;
|
|
4
5
|
|
|
6
|
+
/** A turn's images as Pi takes them with a prompt or a steer. */
|
|
7
|
+
export function promptImages(attachments: TurnAttachments): PromptImages {
|
|
8
|
+
return attachments.images.map((image) => ({
|
|
9
|
+
type: "image" as const,
|
|
10
|
+
...image,
|
|
11
|
+
}));
|
|
12
|
+
}
|
|
13
|
+
|
|
5
14
|
/** The part of a Pi session a steerable run drives. */
|
|
6
15
|
export type SteeringSession = Pick<
|
|
7
16
|
AgentSession,
|