pi-roundtable 0.1.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/docs/plugins.md +924 -0
- package/examples/channels.test.ts +13 -0
- package/examples/channels.ts +27 -0
- package/examples/dashboard.test.ts +11 -0
- package/examples/dashboard.ts +9 -0
- package/examples/events.test.ts +26 -0
- package/examples/events.ts +25 -0
- package/examples/guide.test.ts +52 -0
- package/examples/holds.test.ts +16 -0
- package/examples/holds.ts +30 -0
- package/examples/http.test.ts +12 -0
- package/examples/http.ts +17 -0
- package/examples/interactions.test.ts +25 -0
- package/examples/interactions.ts +28 -0
- package/examples/migrations.test.ts +31 -0
- package/examples/migrations.ts +42 -0
- package/examples/packages.test.ts +9 -0
- package/examples/packages.ts +9 -0
- package/examples/preflight.test.ts +11 -0
- package/examples/preflight.ts +19 -0
- package/examples/prompt.test.ts +25 -0
- package/examples/prompt.ts +20 -0
- package/examples/providers.test.ts +10 -0
- package/examples/providers.ts +17 -0
- package/examples/seeds.test.ts +12 -0
- package/examples/seeds.ts +18 -0
- package/examples/selection.test.ts +13 -0
- package/examples/selection.ts +14 -0
- package/examples/services.test.ts +20 -0
- package/examples/services.ts +30 -0
- package/examples/session-tools.test.ts +24 -0
- package/examples/session-tools.ts +34 -0
- package/examples/tools.test.ts +18 -0
- package/examples/tools.ts +28 -0
- package/package.json +55 -0
- package/src/cli/add-plugin.test.ts +107 -0
- package/src/cli/add-plugin.ts +75 -0
- package/src/cli/checks/basic.test.ts +276 -0
- package/src/cli/checks/bun.ts +26 -0
- package/src/cli/checks/configuration.ts +28 -0
- package/src/cli/checks/database.test.ts +117 -0
- package/src/cli/checks/database.ts +100 -0
- package/src/cli/checks/discord.test.ts +200 -0
- package/src/cli/checks/discord.ts +218 -0
- package/src/cli/checks/environment.ts +27 -0
- package/src/cli/checks/model.ts +28 -0
- package/src/cli/checks/public-url.ts +40 -0
- package/src/cli/cli.test.ts +122 -0
- package/src/cli/cli.ts +166 -0
- package/src/cli/config-edit.test.ts +130 -0
- package/src/cli/config-edit.ts +199 -0
- package/src/cli/discord-api.ts +96 -0
- package/src/cli/doctor.test.ts +184 -0
- package/src/cli/doctor.ts +92 -0
- package/src/cli/http.ts +23 -0
- package/src/cli/init.test.ts +127 -0
- package/src/cli/init.ts +82 -0
- package/src/cli/main.ts +13 -0
- package/src/cli/project.ts +123 -0
- package/src/cli/report.ts +68 -0
- package/src/cli/roundtable.mjs +22 -0
- package/src/cli/runtime.ts +53 -0
- package/src/cli/size.test.ts +9 -0
- package/src/cli/start.ts +33 -0
- package/src/cli/templates.test.ts +129 -0
- package/src/cli/templates.ts +119 -0
- package/src/cli/testing/fixtures.ts +106 -0
- package/src/core/agents/agent-claim.test.ts +142 -0
- package/src/core/agents/agent-claim.ts +170 -0
- package/src/core/agents/agent-dashboard.test.ts +120 -0
- package/src/core/agents/agent-dashboard.ts +185 -0
- package/src/core/agents/agent-guild.test.ts +124 -0
- package/src/core/agents/agent-messages.ts +189 -0
- package/src/core/agents/agent-ports.ts +103 -0
- package/src/core/agents/agent-prompt.ts +163 -0
- package/src/core/agents/agent-rules.ts +106 -0
- package/src/core/agents/agent-schema.ts +115 -0
- package/src/core/agents/agent-settings.ts +49 -0
- package/src/core/agents/agent-store.test.ts +205 -0
- package/src/core/agents/agent-store.ts +384 -0
- package/src/core/agents/agent-team.ts +445 -0
- package/src/core/agents/agent-tools.ts +386 -0
- package/src/core/agents/avatar-studio.test.ts +88 -0
- package/src/core/agents/avatar-studio.ts +147 -0
- package/src/core/agents/group-messages.ts +99 -0
- package/src/core/agents/group-round.test.ts +143 -0
- package/src/core/agents/group-round.ts +117 -0
- package/src/core/agents/group-turns.ts +132 -0
- package/src/core/agents/owner-identity.test.ts +172 -0
- package/src/core/agents/team-editing.ts +205 -0
- package/src/core/agents/team-keys.ts +40 -0
- package/src/core/agents/team-layout.ts +135 -0
- package/src/core/agents/team-lifecycle.ts +188 -0
- package/src/core/agents/team-options.ts +52 -0
- package/src/core/agents/team-status.ts +80 -0
- package/src/core/agents/team-text.ts +60 -0
- package/src/core/agents/team-turn-types.ts +107 -0
- package/src/core/agents/team-turns.test.ts +278 -0
- package/src/core/agents/team-turns.ts +323 -0
- package/src/core/assets/neutral.png +0 -0
- package/src/core/assets/prompts/shared-guest.md +11 -0
- package/src/core/assets/prompts/shared.md +11 -0
- package/src/core/assets/skills/writing-skills/SKILL.md +21 -0
- package/src/core/attachments/attachment-dir.ts +15 -0
- package/src/core/attachments/attachment-fetcher.ts +83 -0
- package/src/core/attachments/attachments.test.ts +85 -0
- package/src/core/attachments/image-prep.ts +50 -0
- package/src/core/attachments/prompt-block.ts +37 -0
- package/src/core/attachments/turn-attachments.ts +79 -0
- package/src/core/boundary.test.ts +45 -0
- package/src/core/builtin/agent-server.ts +275 -0
- package/src/core/builtin/discord.ts +95 -0
- package/src/core/builtin/modules.test.ts +127 -0
- package/src/core/builtin/modules.ts +176 -0
- package/src/core/builtin/seeds.ts +10 -0
- package/src/core/builtin/stores.ts +42 -0
- package/src/core/config/config.test.ts +135 -0
- package/src/core/config/config.ts +285 -0
- package/src/core/config/schema.ts +181 -0
- package/src/core/contract/channels.ts +152 -0
- package/src/core/contract/discord.ts +33 -0
- package/src/core/contract/providers.ts +67 -0
- package/src/core/db/guild-scope.ts +53 -0
- package/src/core/db/migrations.test.ts +237 -0
- package/src/core/db/migrations.ts +37 -0
- package/src/core/define-roundtable.test.ts +134 -0
- package/src/core/define-roundtable.ts +195 -0
- package/src/core/define.test.ts +144 -0
- package/src/core/define.ts +154 -0
- package/src/core/discord/agent-commands.test.ts +58 -0
- package/src/core/discord/agent-commands.ts +385 -0
- package/src/core/discord/agent-discord.test.ts +70 -0
- package/src/core/discord/agent-discord.ts +367 -0
- package/src/core/discord/channel-executor.ts +404 -0
- package/src/core/discord/channel-operations.ts +329 -0
- package/src/core/discord/discord-surface.ts +431 -0
- package/src/core/discord/dispatch-thread-host.test.ts +114 -0
- package/src/core/discord/dispatch-thread-host.ts +72 -0
- package/src/core/discord/dispatch-threads.test.ts +94 -0
- package/src/core/discord/dispatch-threads.ts +180 -0
- package/src/core/discord/interaction-module.ts +1 -0
- package/src/core/discord/owner-cards.test.ts +435 -0
- package/src/core/discord/owner-cards.ts +463 -0
- package/src/core/discord/owner-command.ts +126 -0
- package/src/core/discord/owner-discord-access.ts +183 -0
- package/src/core/discord/owner-discord-threads.ts +160 -0
- package/src/core/discord/owner-discord.test.ts +325 -0
- package/src/core/discord/owner-discord.ts +282 -0
- package/src/core/discord/owner-panel.ts +103 -0
- package/src/core/discord/schedule-commands.ts +149 -0
- package/src/core/discord/stop-button.ts +20 -0
- package/src/core/domain/attachment.ts +39 -0
- package/src/core/domain/conversation.ts +42 -0
- package/src/core/domain/errors.ts +31 -0
- package/src/core/domain/expression.ts +21 -0
- package/src/core/domain/owner-prompts.ts +45 -0
- package/src/core/domain/ports.ts +91 -0
- package/src/core/domain/profile.ts +32 -0
- package/src/core/drain.test.ts +46 -0
- package/src/core/drain.ts +33 -0
- package/src/core/errors.ts +29 -0
- package/src/core/events.test.ts +81 -0
- package/src/core/events.ts +55 -0
- package/src/core/holds.test.ts +54 -0
- package/src/core/holds.ts +46 -0
- package/src/core/host.test.ts +536 -0
- package/src/core/host.ts +297 -0
- package/src/core/http/listeners.test.ts +164 -0
- package/src/core/http/listeners.ts +151 -0
- package/src/core/i18n/agent-panel.ts +96 -0
- package/src/core/i18n/cards.ts +64 -0
- package/src/core/i18n/channels.ts +28 -0
- package/src/core/i18n/conversation.ts +49 -0
- package/src/core/i18n/dashboard.ts +72 -0
- package/src/core/i18n/discord.ts +23 -0
- package/src/core/i18n/en.ts +27 -0
- package/src/core/i18n/i18n.test.ts +139 -0
- package/src/core/i18n/index.ts +58 -0
- package/src/core/i18n/owner.ts +35 -0
- package/src/core/i18n/schedules.ts +92 -0
- package/src/core/i18n/time-zones.ts +27 -0
- package/src/core/i18n/types.ts +7 -0
- package/src/core/i18n/zh-tw.ts +26 -0
- package/src/core/identity.test.ts +18 -0
- package/src/core/identity.ts +31 -0
- package/src/core/judging/confirmation-judge.ts +68 -0
- package/src/core/judging/effort-judge.test.ts +112 -0
- package/src/core/judging/effort-judge.ts +158 -0
- package/src/core/judging/model-judge.test.ts +126 -0
- package/src/core/judging/model-judge.ts +193 -0
- package/src/core/log.ts +45 -0
- package/src/core/models.ts +52 -0
- package/src/core/modules/background/background-turns.ts +78 -0
- package/src/core/modules/delegation/delegate.ts +54 -0
- package/src/core/modules/delegation/delegator.test.ts +170 -0
- package/src/core/modules/delegation/delegator.ts +191 -0
- package/src/core/modules/delegation/sol-worker.ts +78 -0
- package/src/core/modules/discord-admin/discord-admin.ts +288 -0
- package/src/core/modules/host-shell/shell-policy.ts +386 -0
- package/src/core/modules/memory/owner-memory-store.test.ts +244 -0
- package/src/core/modules/memory/owner-memory-store.ts +228 -0
- package/src/core/modules/memory/owner-memory.ts +153 -0
- package/src/core/modules/notify/notify.ts +26 -0
- package/src/core/modules/schedules/recurrence.ts +206 -0
- package/src/core/modules/schedules/schedule-store.ts +213 -0
- package/src/core/modules/schedules/schedule-tools.ts +196 -0
- package/src/core/modules/schedules/schedule.test.ts +358 -0
- package/src/core/modules/schedules/scheduler.ts +121 -0
- package/src/core/modules/schedules/schedules.ts +87 -0
- package/src/core/modules/skills/repo-name.ts +11 -0
- package/src/core/modules/skills/skill-kind.test.ts +55 -0
- package/src/core/modules/skills/skill-link.ts +69 -0
- package/src/core/modules/skills/skill-listing.ts +128 -0
- package/src/core/modules/skills/skill-registry.test.ts +376 -0
- package/src/core/modules/skills/skill-registry.ts +323 -0
- package/src/core/modules/skills/skill-rules.ts +120 -0
- package/src/core/modules/skills/skill-store.ts +285 -0
- package/src/core/modules/skills/skill-tools.ts +207 -0
- package/src/core/ops/error-reporter.test.ts +283 -0
- package/src/core/ops/error-reporter.ts +271 -0
- package/src/core/plugin.ts +200 -0
- package/src/core/presentation/card-cadence.ts +41 -0
- package/src/core/presentation/headline.ts +34 -0
- package/src/core/presentation/presentation.test.ts +166 -0
- package/src/core/presentation/quiet-links.ts +36 -0
- package/src/core/presentation/reply-splitter.ts +110 -0
- package/src/core/presentation/thinking-line.ts +24 -0
- package/src/core/public-entry.test.ts +59 -0
- package/src/core/registry/contributions.test.ts +215 -0
- package/src/core/registry/contributions.ts +249 -0
- package/src/core/registry/interactions.test.ts +87 -0
- package/src/core/registry/interactions.ts +55 -0
- package/src/core/registry/providers.test.ts +103 -0
- package/src/core/registry/providers.ts +55 -0
- package/src/core/routing/channel-queue.test.ts +31 -0
- package/src/core/routing/channel-queue.ts +44 -0
- package/src/core/routing/channel-router.test.ts +326 -0
- package/src/core/routing/channel-router.ts +157 -0
- package/src/core/routing/conversation-kind.ts +14 -0
- package/src/core/routing/forward-join.ts +65 -0
- package/src/core/routing/message-text.ts +16 -0
- package/src/core/routing/settle-turn.test.ts +21 -0
- package/src/core/routing/settle-turn.ts +26 -0
- package/src/core/runtime/compaction-tiers.test.ts +227 -0
- package/src/core/runtime/compaction-tiers.ts +203 -0
- package/src/core/runtime/conversation-sessions.ts +162 -0
- package/src/core/runtime/extensions/agent-prompt.ts +16 -0
- package/src/core/runtime/extensions/ask-user.test.ts +114 -0
- package/src/core/runtime/extensions/ask-user.ts +98 -0
- package/src/core/runtime/extensions/confirmation-gate.ts +247 -0
- package/src/core/runtime/extensions/self-compact-guard.test.ts +50 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +23 -0
- package/src/core/runtime/mcp.ts +23 -0
- package/src/core/runtime/pending-confirmation-store.test.ts +52 -0
- package/src/core/runtime/pending-confirmation-store.ts +75 -0
- package/src/core/runtime/pi-agent-runtime.ts +477 -0
- package/src/core/runtime/prompt-slot.ts +81 -0
- package/src/core/runtime/runtime-types.ts +150 -0
- package/src/core/runtime/session-archive.test.ts +20 -0
- package/src/core/runtime/session-archive.ts +17 -0
- package/src/core/runtime/session-factory.ts +291 -0
- package/src/core/runtime/steerable-run.test.ts +264 -0
- package/src/core/runtime/steerable-run.ts +109 -0
- package/src/core/runtime/text-tools.test.ts +110 -0
- package/src/core/runtime/text-tools.ts +66 -0
- package/src/core/runtime/turn-answer.test.ts +72 -0
- package/src/core/runtime/turn-answer.ts +79 -0
- package/src/core/runtime/worker-task.test.ts +82 -0
- package/src/core/runtime/worker-task.ts +65 -0
- package/src/core/services.test.ts +28 -0
- package/src/core/services.ts +107 -0
- package/src/core/sessions.test.ts +85 -0
- package/src/core/sessions.ts +149 -0
- package/src/core/shared/attachment-reader.ts +91 -0
- package/src/core/shared/delegate-tool.ts +15 -0
- package/src/core/shared/mcp-adapter.ts +58 -0
- package/src/core/shared/package-dir.ts +9 -0
- package/src/core/shared/profile-tools.ts +16 -0
- package/src/core/shared/read-attachment-tool.ts +43 -0
- package/src/core/shared/schedule-tools.ts +118 -0
- package/src/core/shared/session-messages.ts +50 -0
- package/src/core/shared/tool-result.ts +9 -0
- package/src/core/shared/unix-server.ts +18 -0
- package/src/core/size.test.ts +9 -0
- package/src/core/speakers.test.ts +78 -0
- package/src/core/speakers.ts +143 -0
- package/src/core/testing/database.ts +52 -0
- package/src/core/testing/file-size.ts +34 -0
- package/src/core/testing/locale.ts +8 -0
- package/src/core/testing/modules.ts +97 -0
- package/src/core/testing/owner.ts +15 -0
- package/src/core/testing/thread-host.ts +60 -0
- package/src/core/time.test.ts +166 -0
- package/src/core/time.ts +67 -0
- package/src/core/tool-tiers.test.ts +117 -0
- package/src/core/tool-tiers.ts +127 -0
- package/src/entries.test.ts +112 -0
- package/src/index.ts +28 -0
- package/src/testing.test.ts +156 -0
- package/src/testing.ts +182 -0
- package/templates/.env.example +22 -0
- package/templates/README.md +11 -0
- package/templates/_gitignore +3 -0
- package/templates/agents.ts +12 -0
- package/templates/biome.json.tmpl +7 -0
- package/templates/docker-compose.yml +16 -0
- package/templates/package.json.tmpl +24 -0
- package/templates/persona/shared.md +2 -0
- package/templates/plugin/plugin.test.ts.tmpl +13 -0
- package/templates/plugin/plugin.ts +18 -0
- package/templates/roundtable.config.ts +24 -0
- package/templates/tsconfig.json.tmpl +16 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InboundMessage } from "../domain/conversation.ts";
|
|
2
|
+
import type { OwnerIdentity } from "../identity.ts";
|
|
3
|
+
|
|
4
|
+
/** The message text, then the message it replies to and the message it forwards, when there are. */
|
|
5
|
+
export function withReference(
|
|
6
|
+
message: InboundMessage,
|
|
7
|
+
owner: OwnerIdentity,
|
|
8
|
+
): string {
|
|
9
|
+
const quoted = message.reference?.text.trim();
|
|
10
|
+
const replied = quoted
|
|
11
|
+
? `${message.text}\n\n## The message this replies to\n${quoted}`
|
|
12
|
+
: message.text;
|
|
13
|
+
const forwarded = message.forwarded;
|
|
14
|
+
if (!forwarded) return replied;
|
|
15
|
+
return `${replied}\n\n## Forwarded by ${owner.name} from ${forwarded.channelMention} (${forwarded.url})\n${forwarded.text.trim() || "(no text; see the attachments)"}`.trim();
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
import { settleTurn } from "./settle-turn.ts";
|
|
4
|
+
|
|
5
|
+
describe("settleTurn", () => {
|
|
6
|
+
test("passes a finished turn's result through", async () => {
|
|
7
|
+
expect(await settleTurn(async () => ({ ok: true }), "turn")).toEqual({
|
|
8
|
+
ok: true,
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("turns a thrown error into a failed result naming what crashed", async () => {
|
|
13
|
+
const result = await settleTurn(async () => {
|
|
14
|
+
throw new Error("socket closed");
|
|
15
|
+
}, "party turn");
|
|
16
|
+
expect(result).toEqual({
|
|
17
|
+
ok: false,
|
|
18
|
+
error: new AgentRunError("party turn crashed: Error: socket closed"),
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ScheduledOutcome } from "../contract/channels.ts";
|
|
2
|
+
import { AgentRunError } from "../domain/errors.ts";
|
|
3
|
+
|
|
4
|
+
/** Runs a turn; a thrown error becomes its failed result, labelled `<what> crashed`. */
|
|
5
|
+
export async function settleTurn<R>(
|
|
6
|
+
run: () => Promise<R>,
|
|
7
|
+
what: string,
|
|
8
|
+
): Promise<R | { ok: false; error: AgentRunError }> {
|
|
9
|
+
try {
|
|
10
|
+
return await run();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
return {
|
|
13
|
+
ok: false,
|
|
14
|
+
error: new AgentRunError(`${what} crashed: ${String(error)}`),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The result of a turn nobody wrote, as the scheduler and reports see it. */
|
|
20
|
+
export function outcome(
|
|
21
|
+
result: { ok: true } | { ok: false; error: { message: string } },
|
|
22
|
+
): ScheduledOutcome {
|
|
23
|
+
return result.ok
|
|
24
|
+
? { status: "ran" }
|
|
25
|
+
: { status: "failed", error: result.error.message };
|
|
26
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
SessionManager,
|
|
5
|
+
shouldCompact,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
CompactionTiers,
|
|
9
|
+
compactionEngine,
|
|
10
|
+
HARD_COMPACT_TOKENS,
|
|
11
|
+
SOFT_COMPACT_TOKENS,
|
|
12
|
+
} from "./compaction-tiers.ts";
|
|
13
|
+
|
|
14
|
+
const WINDOWS: Record<string, number> = {
|
|
15
|
+
"anthropic/claude-opus-5-5": 1_000_000,
|
|
16
|
+
"anthropic/claude-200k-opus-5-5": 200_000,
|
|
17
|
+
};
|
|
18
|
+
const LARGE = { provider: "anthropic", id: "claude-opus-5-5" };
|
|
19
|
+
const SMALL = { provider: "anthropic", id: "claude-200k-opus-5-5" };
|
|
20
|
+
const PI_RESERVE = 16_384;
|
|
21
|
+
/** The `engine` a compaction extension records in its compactions' details. */
|
|
22
|
+
const EXTENSION_ENGINE = "acme-compaction";
|
|
23
|
+
|
|
24
|
+
/** pi estimates a message at a token per four characters. */
|
|
25
|
+
function text(tokens: number): string {
|
|
26
|
+
return "x".repeat(tokens * 4);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function say(session: SessionManager, tokens: number): string {
|
|
30
|
+
return session.appendMessage({
|
|
31
|
+
role: "user",
|
|
32
|
+
content: text(tokens),
|
|
33
|
+
timestamp: Date.now(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Appends a compaction that leaves about `left` tokens: its summary and one small kept message. */
|
|
38
|
+
function compacted(
|
|
39
|
+
session: SessionManager,
|
|
40
|
+
engine: "extension" | "pi",
|
|
41
|
+
left: number,
|
|
42
|
+
): void {
|
|
43
|
+
const kept = say(session, 1_000);
|
|
44
|
+
session.appendCompaction(
|
|
45
|
+
text(left - 1_000),
|
|
46
|
+
kept,
|
|
47
|
+
SOFT_COMPACT_TOKENS,
|
|
48
|
+
engine === "extension" ? { engine: EXTENSION_ENGINE } : {},
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tiersOver(session: SessionManager): CompactionTiers {
|
|
53
|
+
return new CompactionTiers(
|
|
54
|
+
session,
|
|
55
|
+
(provider, id) => WINDOWS[`${provider}/${id}`],
|
|
56
|
+
EXTENSION_ENGINE,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The context size the model compacts past, as pi's own check sees it. */
|
|
61
|
+
function trigger(tiers: CompactionTiers, model = LARGE): number {
|
|
62
|
+
const settings = tiers.settings().getCompactionSettings(model);
|
|
63
|
+
const window = WINDOWS[`${model.provider}/${model.id}`] ?? 0;
|
|
64
|
+
const at = window - settings.reserveTokens;
|
|
65
|
+
expect(shouldCompact(at, window, settings)).toBe(false);
|
|
66
|
+
expect(shouldCompact(at + 1, window, settings)).toBe(true);
|
|
67
|
+
return at;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Handler = (event: unknown, ctx: unknown) => unknown;
|
|
71
|
+
|
|
72
|
+
/** Loads a stand-in for a compaction extension through the tiers' wrapper. */
|
|
73
|
+
function loadExtension(tiers: CompactionTiers) {
|
|
74
|
+
const extensionCalls: number[] = [];
|
|
75
|
+
const bypasses: { reason: string; tokensBefore: number }[] = [];
|
|
76
|
+
const handlers = new Map<string, Handler>();
|
|
77
|
+
const onStart: Handler = () => undefined;
|
|
78
|
+
tiers.wrapCompactor(
|
|
79
|
+
(pi) => {
|
|
80
|
+
pi.on("session_start", onStart as never);
|
|
81
|
+
pi.on("session_before_compact", (event) => {
|
|
82
|
+
extensionCalls.push(event.preparation.tokensBefore);
|
|
83
|
+
return { compaction: { summary: "extension" } } as never;
|
|
84
|
+
});
|
|
85
|
+
},
|
|
86
|
+
(bypass) => bypasses.push(bypass),
|
|
87
|
+
)({
|
|
88
|
+
on: (event: string, handler: Handler) => handlers.set(event, handler),
|
|
89
|
+
} as unknown as ExtensionAPI);
|
|
90
|
+
const compact = (tokensBefore: number) =>
|
|
91
|
+
handlers.get("session_before_compact")?.(
|
|
92
|
+
{ type: "session_before_compact", preparation: { tokensBefore } },
|
|
93
|
+
{},
|
|
94
|
+
);
|
|
95
|
+
return { compact, extensionCalls, bypasses, onStart, handlers };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe("soft threshold", () => {
|
|
99
|
+
test("a 1M model compacts past 300k", () => {
|
|
100
|
+
const tiers = tiersOver(SessionManager.inMemory("/tmp"));
|
|
101
|
+
expect(trigger(tiers)).toBe(SOFT_COMPACT_TOKENS);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("a 200k model keeps pi's default reserve", () => {
|
|
105
|
+
const tiers = tiersOver(SessionManager.inMemory("/tmp"));
|
|
106
|
+
expect(tiers.settings().getCompactionSettings(SMALL).reserveTokens).toBe(
|
|
107
|
+
PI_RESERVE,
|
|
108
|
+
);
|
|
109
|
+
expect(trigger(tiers, SMALL)).toBe(200_000 - PI_RESERVE);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("an unknown model keeps pi's default reserve", () => {
|
|
113
|
+
const tiers = tiersOver(SessionManager.inMemory("/tmp"));
|
|
114
|
+
expect(
|
|
115
|
+
tiers.settings().getCompactionSettings({ provider: "x", id: "y" })
|
|
116
|
+
.reserveTokens,
|
|
117
|
+
).toBe(PI_RESERVE);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("hard ceiling", () => {
|
|
122
|
+
test("a compaction above 500k skips the extension, so pi's summary runs", async () => {
|
|
123
|
+
const extension = loadExtension(tiersOver(SessionManager.inMemory("/tmp")));
|
|
124
|
+
expect(await extension.compact(HARD_COMPACT_TOKENS + 1)).toBeUndefined();
|
|
125
|
+
expect(extension.extensionCalls).toEqual([]);
|
|
126
|
+
expect(extension.bypasses).toEqual([
|
|
127
|
+
{
|
|
128
|
+
reason: "above the hard ceiling",
|
|
129
|
+
tokensBefore: HARD_COMPACT_TOKENS + 1,
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a compaction at or below 500k goes to the extension", async () => {
|
|
135
|
+
const extension = loadExtension(tiersOver(SessionManager.inMemory("/tmp")));
|
|
136
|
+
expect(await extension.compact(HARD_COMPACT_TOKENS)).toEqual({
|
|
137
|
+
compaction: { summary: "extension" },
|
|
138
|
+
});
|
|
139
|
+
expect(await extension.compact(SOFT_COMPACT_TOKENS + 1)).toEqual({
|
|
140
|
+
compaction: { summary: "extension" },
|
|
141
|
+
});
|
|
142
|
+
expect(extension.extensionCalls).toEqual([
|
|
143
|
+
HARD_COMPACT_TOKENS,
|
|
144
|
+
SOFT_COMPACT_TOKENS + 1,
|
|
145
|
+
]);
|
|
146
|
+
expect(extension.bypasses).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("after the extension left more than 500k, the next compaction is pi's", async () => {
|
|
150
|
+
const session = SessionManager.inMemory("/tmp");
|
|
151
|
+
say(session, 10_000);
|
|
152
|
+
compacted(session, "extension", 520_000);
|
|
153
|
+
const tiers = tiersOver(session);
|
|
154
|
+
expect(tiers.latest()?.contextTokens).toBeGreaterThan(HARD_COMPACT_TOKENS);
|
|
155
|
+
const extension = loadExtension(tiers);
|
|
156
|
+
expect(await extension.compact(450_000)).toBeUndefined();
|
|
157
|
+
expect(extension.extensionCalls).toEqual([]);
|
|
158
|
+
expect(extension.bypasses[0]?.reason).toContain(
|
|
159
|
+
"The extension's last compaction",
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("only session_before_compact is wrapped", () => {
|
|
164
|
+
const extension = loadExtension(tiersOver(SessionManager.inMemory("/tmp")));
|
|
165
|
+
expect(extension.handlers.get("session_start")).toBe(extension.onStart);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("no compaction loop", () => {
|
|
170
|
+
test("the extension leaving the context near 300k moves the next compaction to 500k", () => {
|
|
171
|
+
const session = SessionManager.inMemory("/tmp");
|
|
172
|
+
say(session, 10_000);
|
|
173
|
+
compacted(session, "extension", 310_000);
|
|
174
|
+
const tiers = tiersOver(session);
|
|
175
|
+
// The same request's next check sees about what the extension left, and does not compact again.
|
|
176
|
+
const settings = tiers.settings().getCompactionSettings(LARGE);
|
|
177
|
+
expect(shouldCompact(311_000, 1_000_000, settings)).toBe(false);
|
|
178
|
+
expect(trigger(tiers)).toBe(HARD_COMPACT_TOKENS);
|
|
179
|
+
|
|
180
|
+
session.appendMessage({
|
|
181
|
+
role: "user",
|
|
182
|
+
content: "small",
|
|
183
|
+
timestamp: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
compacted(session, "extension", 280_000);
|
|
186
|
+
expect(trigger(tiers)).toBe(HARD_COMPACT_TOKENS);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a compaction well below 300k keeps the soft threshold", () => {
|
|
190
|
+
const session = SessionManager.inMemory("/tmp");
|
|
191
|
+
say(session, 10_000);
|
|
192
|
+
compacted(session, "extension", 310_000);
|
|
193
|
+
const tiers = tiersOver(session);
|
|
194
|
+
expect(trigger(tiers)).toBe(HARD_COMPACT_TOKENS);
|
|
195
|
+
// pi's summary at the ceiling brings the session back to the soft tier.
|
|
196
|
+
compacted(session, "pi", 30_000);
|
|
197
|
+
expect(trigger(tiers)).toBe(SOFT_COMPACT_TOKENS);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("pi's summary leaving the context near 500k falls back to pi's own threshold", () => {
|
|
201
|
+
const session = SessionManager.inMemory("/tmp");
|
|
202
|
+
say(session, 10_000);
|
|
203
|
+
compacted(session, "pi", 480_000);
|
|
204
|
+
expect(trigger(tiersOver(session))).toBe(1_000_000 - PI_RESERVE);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("the extension leaving the context near 500k still leaves pi the ceiling", () => {
|
|
208
|
+
const session = SessionManager.inMemory("/tmp");
|
|
209
|
+
say(session, 10_000);
|
|
210
|
+
compacted(session, "extension", 480_000);
|
|
211
|
+
expect(trigger(tiersOver(session))).toBe(HARD_COMPACT_TOKENS);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("compaction engine", () => {
|
|
216
|
+
test("a compaction is the extension's only when it records the engine the plugin declared", () => {
|
|
217
|
+
expect(
|
|
218
|
+
compactionEngine({ engine: EXTENSION_ENGINE }, EXTENSION_ENGINE),
|
|
219
|
+
).toBe("extension");
|
|
220
|
+
expect(compactionEngine({ engine: "other" }, EXTENSION_ENGINE)).toBe("pi");
|
|
221
|
+
expect(compactionEngine({}, EXTENSION_ENGINE)).toBe("pi");
|
|
222
|
+
expect(compactionEngine(undefined, EXTENSION_ENGINE)).toBe("pi");
|
|
223
|
+
expect(compactionEngine({ engine: EXTENSION_ENGINE }, undefined)).toBe(
|
|
224
|
+
"pi",
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
type ExtensionFactory,
|
|
4
|
+
estimateTokens,
|
|
5
|
+
type SessionBeforeCompactEvent,
|
|
6
|
+
type SessionManager,
|
|
7
|
+
SettingsManager,
|
|
8
|
+
} from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
/** Past this, a large-window session compacts through the compaction extension. */
|
|
11
|
+
export const SOFT_COMPACT_TOKENS = 300_000;
|
|
12
|
+
/** Past this, a compaction is pi's own summary, never the extension's. */
|
|
13
|
+
export const HARD_COMPACT_TOKENS = 500_000;
|
|
14
|
+
/**
|
|
15
|
+
* How far below a threshold a compaction must leave the context for that threshold to fire
|
|
16
|
+
* again, so a compaction that barely helped does not repeat on the next request.
|
|
17
|
+
*/
|
|
18
|
+
export const COMPACT_HEADROOM_TOKENS = 50_000;
|
|
19
|
+
|
|
20
|
+
/** Who wrote a compaction: the plugin's compaction extension or Pi's own summary. */
|
|
21
|
+
export type CompactionEngine = "extension" | "pi";
|
|
22
|
+
|
|
23
|
+
/** The session reads the tiers need; a SessionManager or an extension's read-only one. */
|
|
24
|
+
export type CompactionHistory = Pick<
|
|
25
|
+
SessionManager,
|
|
26
|
+
"getBranch" | "buildSessionProjection"
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
export interface LatestCompaction {
|
|
30
|
+
id: string;
|
|
31
|
+
engine: CompactionEngine;
|
|
32
|
+
/** Estimated context the compaction left: its summary and the entries it kept. */
|
|
33
|
+
contextTokens: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The extension's compactions record its `engine` in their details; Pi's own do not. */
|
|
37
|
+
export function compactionEngine(
|
|
38
|
+
details: unknown,
|
|
39
|
+
extensionEngine: string | undefined,
|
|
40
|
+
): CompactionEngine {
|
|
41
|
+
const engine = (details as { engine?: unknown } | undefined)?.engine;
|
|
42
|
+
return extensionEngine !== undefined && engine === extensionEngine
|
|
43
|
+
? "extension"
|
|
44
|
+
: "pi";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The branch's latest compaction, measured from the session as it stands now. */
|
|
48
|
+
export function latestCompaction(
|
|
49
|
+
history: CompactionHistory,
|
|
50
|
+
extensionEngine: string | undefined,
|
|
51
|
+
): LatestCompaction | undefined {
|
|
52
|
+
const branch = history.getBranch();
|
|
53
|
+
const index = branch.findLastIndex((entry) => entry.type === "compaction");
|
|
54
|
+
const entry = branch[index];
|
|
55
|
+
if (entry?.type !== "compaction") return undefined;
|
|
56
|
+
const upToCompaction = new Set(
|
|
57
|
+
branch.slice(0, index + 1).map((each) => each.id),
|
|
58
|
+
);
|
|
59
|
+
let contextTokens = 0;
|
|
60
|
+
for (const projected of history.buildSessionProjection().entries) {
|
|
61
|
+
if (!upToCompaction.has(projected.sourceEntry.id)) continue;
|
|
62
|
+
for (const message of projected.messages)
|
|
63
|
+
contextTokens += estimateTokens(message);
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
id: entry.id,
|
|
67
|
+
engine: compactionEngine(entry.details, extensionEngine),
|
|
68
|
+
contextTokens,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The context size a session compacts past, or undefined for pi's own threshold. Large windows
|
|
74
|
+
* compact at the soft threshold; a compaction that left the context near or above it moves the
|
|
75
|
+
* next one to the hard ceiling, and one of pi's that left it near the ceiling to pi's threshold.
|
|
76
|
+
*/
|
|
77
|
+
export function compactionTrigger(
|
|
78
|
+
contextWindow: number,
|
|
79
|
+
reserveTokens: number,
|
|
80
|
+
latest: LatestCompaction | undefined,
|
|
81
|
+
): number | undefined {
|
|
82
|
+
const native = contextWindow - reserveTokens;
|
|
83
|
+
if (native <= SOFT_COMPACT_TOKENS) return undefined;
|
|
84
|
+
const left = latest?.contextTokens ?? 0;
|
|
85
|
+
if (left <= SOFT_COMPACT_TOKENS - COMPACT_HEADROOM_TOKENS)
|
|
86
|
+
return SOFT_COMPACT_TOKENS;
|
|
87
|
+
if (native <= HARD_COMPACT_TOKENS) return undefined;
|
|
88
|
+
// Only pi's summary fires at the ceiling, so the extension's compaction near it still leaves pi a turn.
|
|
89
|
+
const piStalled =
|
|
90
|
+
latest?.engine === "pi" &&
|
|
91
|
+
left > HARD_COMPACT_TOKENS - COMPACT_HEADROOM_TOKENS;
|
|
92
|
+
return piStalled ? undefined : HARD_COMPACT_TOKENS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Why a compaction of this size must be pi's summary rather than the extension's, if it must. */
|
|
96
|
+
export function extensionBypass(
|
|
97
|
+
tokensBefore: number,
|
|
98
|
+
latest: LatestCompaction | undefined,
|
|
99
|
+
): string | undefined {
|
|
100
|
+
if (tokensBefore > HARD_COMPACT_TOKENS) return "above the hard ceiling";
|
|
101
|
+
if (
|
|
102
|
+
latest?.engine === "extension" &&
|
|
103
|
+
latest.contextTokens > HARD_COMPACT_TOKENS
|
|
104
|
+
)
|
|
105
|
+
return "The extension's last compaction left the context above the hard ceiling";
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** One session's compaction tiers, measured from its own history. */
|
|
110
|
+
export class CompactionTiers {
|
|
111
|
+
readonly #history: CompactionHistory;
|
|
112
|
+
readonly #contextWindow: (provider: string, id: string) => number | undefined;
|
|
113
|
+
readonly #extensionEngine: string | undefined;
|
|
114
|
+
#cached: { id: string; latest: LatestCompaction } | undefined;
|
|
115
|
+
|
|
116
|
+
constructor(
|
|
117
|
+
history: CompactionHistory,
|
|
118
|
+
contextWindow: (provider: string, id: string) => number | undefined,
|
|
119
|
+
/** The `engine` the compaction extension records in its compactions' details. */
|
|
120
|
+
extensionEngine?: string,
|
|
121
|
+
) {
|
|
122
|
+
this.#history = history;
|
|
123
|
+
this.#contextWindow = contextWindow;
|
|
124
|
+
this.#extensionEngine = extensionEngine;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The latest compaction; measured once per compaction, since pi checks before every request. */
|
|
128
|
+
latest(): LatestCompaction | undefined {
|
|
129
|
+
const branch = this.#history.getBranch();
|
|
130
|
+
const entry = branch.findLast((each) => each.type === "compaction");
|
|
131
|
+
if (!entry) return undefined;
|
|
132
|
+
if (this.#cached?.id !== entry.id) {
|
|
133
|
+
const latest = latestCompaction(this.#history, this.#extensionEngine);
|
|
134
|
+
if (!latest) return undefined;
|
|
135
|
+
this.#cached = { id: entry.id, latest };
|
|
136
|
+
}
|
|
137
|
+
return this.#cached.latest;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The context size the model compacts past, or undefined for pi's own threshold. */
|
|
141
|
+
trigger(
|
|
142
|
+
model: { provider: string; id: string },
|
|
143
|
+
reserveTokens: number,
|
|
144
|
+
): number | undefined {
|
|
145
|
+
const window = this.#contextWindow(model.provider, model.id);
|
|
146
|
+
if (!window) return undefined;
|
|
147
|
+
return compactionTrigger(window, reserveTokens, this.latest());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** pi's in-memory settings, with each model's reserve set so it compacts at its tier. */
|
|
151
|
+
settings(): SettingsManager {
|
|
152
|
+
const settings = SettingsManager.inMemory({});
|
|
153
|
+
const base = settings.getCompactionSettings.bind(settings);
|
|
154
|
+
settings.getCompactionSettings = (model) => {
|
|
155
|
+
const resolved = base(model);
|
|
156
|
+
const window = model && this.#contextWindow(model.provider, model.id);
|
|
157
|
+
const trigger = model && this.trigger(model, resolved.reserveTokens);
|
|
158
|
+
if (!window || trigger === undefined) return resolved;
|
|
159
|
+
return { ...resolved, reserveTokens: window - trigger };
|
|
160
|
+
};
|
|
161
|
+
return settings;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wraps a compaction extension so a compaction past the hard ceiling skips it and pi's summary
|
|
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.
|
|
168
|
+
*/
|
|
169
|
+
wrapCompactor(
|
|
170
|
+
compactor: ExtensionFactory,
|
|
171
|
+
onBypass: (details: { reason: string; tokensBefore: number }) => void,
|
|
172
|
+
): ExtensionFactory {
|
|
173
|
+
const gate = (event: SessionBeforeCompactEvent): boolean => {
|
|
174
|
+
const { tokensBefore } = event.preparation;
|
|
175
|
+
const reason = extensionBypass(tokensBefore, this.latest());
|
|
176
|
+
if (reason) onBypass({ reason, tokensBefore });
|
|
177
|
+
return reason === undefined;
|
|
178
|
+
};
|
|
179
|
+
type Handler = (event: unknown, ctx: unknown) => unknown;
|
|
180
|
+
return (pi) => {
|
|
181
|
+
const register = pi.on.bind(pi) as (
|
|
182
|
+
event: string,
|
|
183
|
+
handler: Handler,
|
|
184
|
+
) => () => void;
|
|
185
|
+
const on = ((event: string, handler: Handler) =>
|
|
186
|
+
register(
|
|
187
|
+
event,
|
|
188
|
+
event === "session_before_compact"
|
|
189
|
+
? (compaction, ctx) =>
|
|
190
|
+
gate(compaction as SessionBeforeCompactEvent)
|
|
191
|
+
? handler(compaction, ctx)
|
|
192
|
+
: undefined
|
|
193
|
+
: handler,
|
|
194
|
+
)) as ExtensionAPI["on"];
|
|
195
|
+
return compactor(
|
|
196
|
+
new Proxy(pi, {
|
|
197
|
+
get: (target, prop, receiver) =>
|
|
198
|
+
prop === "on" ? on : Reflect.get(target, prop, receiver),
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { ownerAttachmentDir } from "../attachments/attachment-dir.ts";
|
|
4
|
+
import type {
|
|
5
|
+
ChannelKey,
|
|
6
|
+
PendingConfirmation,
|
|
7
|
+
} from "../domain/conversation.ts";
|
|
8
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
9
|
+
import type { ThinkingLevel } from "../models.ts";
|
|
10
|
+
import type { ToolTiers } from "../tool-tiers.ts";
|
|
11
|
+
import { ConfirmationGate } from "./extensions/confirmation-gate.ts";
|
|
12
|
+
import { PromptSlot } from "./prompt-slot.ts";
|
|
13
|
+
import type { ChannelSession, PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
14
|
+
import { revisionsKey, skillsKey } from "./runtime-types.ts";
|
|
15
|
+
import type { SessionFactory } from "./session-factory.ts";
|
|
16
|
+
|
|
17
|
+
/** The open sessions of a runtime's conversations and what is held for each of them. */
|
|
18
|
+
export class ConversationSessions {
|
|
19
|
+
readonly #options: PiAgentRuntimeOptions;
|
|
20
|
+
readonly #factory: SessionFactory;
|
|
21
|
+
readonly #tiers: ToolTiers;
|
|
22
|
+
readonly #sessions = new Map<ChannelKey, Promise<ChannelSession>>();
|
|
23
|
+
readonly #gates = new Map<ChannelKey, ConfirmationGate>();
|
|
24
|
+
/** Each conversation's way to ask the owner during its current turn. */
|
|
25
|
+
readonly #slots = new Map<ChannelKey, PromptSlot>();
|
|
26
|
+
/** Each conversation's level the judge picked for its last turn, kept while the judge is unsure. */
|
|
27
|
+
readonly judged = new Map<ChannelKey, ThinkingLevel>();
|
|
28
|
+
/** Each conversation's context use after its latest turn, for the agent dashboard. */
|
|
29
|
+
readonly usage = new Map<
|
|
30
|
+
ChannelKey,
|
|
31
|
+
{ tokens: number | null; contextWindow: number }
|
|
32
|
+
>();
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
options: PiAgentRuntimeOptions,
|
|
36
|
+
factory: SessionFactory,
|
|
37
|
+
tiers: ToolTiers,
|
|
38
|
+
) {
|
|
39
|
+
this.#options = options;
|
|
40
|
+
this.#factory = factory;
|
|
41
|
+
this.#tiers = tiers;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The held actions of a conversation whose gate is open. */
|
|
45
|
+
pending(channel: ChannelKey): PendingConfirmation | undefined {
|
|
46
|
+
return this.#gates.get(channel)?.pending();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Disposes every open session. */
|
|
50
|
+
dispose(): void {
|
|
51
|
+
for (const pending of this.#sessions.values()) {
|
|
52
|
+
void pending
|
|
53
|
+
.then(({ session }) => session.dispose())
|
|
54
|
+
.catch(() => undefined);
|
|
55
|
+
}
|
|
56
|
+
this.#sessions.clear();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One gate per conversation, restored from the store the first time it is used. An agent's
|
|
61
|
+
* gate also judges its shell and file tools against the shared workspace.
|
|
62
|
+
*/
|
|
63
|
+
async gate(key: ChannelKey, agent: boolean): Promise<ConfirmationGate> {
|
|
64
|
+
let gate = this.#gates.get(key);
|
|
65
|
+
if (!gate) {
|
|
66
|
+
const workspace = agent ? this.#options.agents?.workDir : undefined;
|
|
67
|
+
gate = new ConfirmationGate(
|
|
68
|
+
this.#factory.link().holds,
|
|
69
|
+
this.#options.owner,
|
|
70
|
+
await this.#options.confirmations.load(key),
|
|
71
|
+
workspace,
|
|
72
|
+
this.#tiers,
|
|
73
|
+
);
|
|
74
|
+
this.#gates.set(key, gate);
|
|
75
|
+
}
|
|
76
|
+
return gate;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
slot(key: ChannelKey): PromptSlot {
|
|
80
|
+
let slot = this.#slots.get(key);
|
|
81
|
+
if (!slot) {
|
|
82
|
+
slot = new PromptSlot();
|
|
83
|
+
this.#slots.set(key, slot);
|
|
84
|
+
}
|
|
85
|
+
return slot;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The conversation's session, created on first use. `request` says what a new one is for:
|
|
90
|
+
* an agent session gets the agent tools; absent, an owner channel's session is opened.
|
|
91
|
+
*/
|
|
92
|
+
session(
|
|
93
|
+
key: ChannelKey,
|
|
94
|
+
request?: Pick<TurnRequest, "channel" | "agent">,
|
|
95
|
+
): Promise<ChannelSession> {
|
|
96
|
+
let pending = this.#sessions.get(key);
|
|
97
|
+
if (!pending) {
|
|
98
|
+
const sessionDir = this.#factory.sessionDir(key);
|
|
99
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
100
|
+
const channel = request?.channel ?? key;
|
|
101
|
+
const agent = request?.agent;
|
|
102
|
+
pending = (async () =>
|
|
103
|
+
this.#factory.create(
|
|
104
|
+
agent?.home ?? channel,
|
|
105
|
+
SessionManager.continueRecent(this.#factory.cwd(agent), sessionDir),
|
|
106
|
+
await this.gate(key, agent !== undefined),
|
|
107
|
+
this.slot(key),
|
|
108
|
+
ownerAttachmentDir(this.#options.dataDir, channel),
|
|
109
|
+
agent,
|
|
110
|
+
))();
|
|
111
|
+
pending.catch(() => this.#sessions.delete(key));
|
|
112
|
+
this.#sessions.set(key, pending);
|
|
113
|
+
}
|
|
114
|
+
return pending;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The conversation's session, rebuilt with its history when its connectors or skills changed. */
|
|
118
|
+
async freshSession(
|
|
119
|
+
key: ChannelKey,
|
|
120
|
+
request: TurnRequest,
|
|
121
|
+
): Promise<ChannelSession> {
|
|
122
|
+
const channelSession = await this.session(key, request);
|
|
123
|
+
const stale = this.#staleReason(channelSession, request);
|
|
124
|
+
if (!stale) return channelSession;
|
|
125
|
+
this.#options.logger.info(
|
|
126
|
+
{ channel: key },
|
|
127
|
+
`${stale}; rebuilding the session`,
|
|
128
|
+
);
|
|
129
|
+
channelSession.session.dispose();
|
|
130
|
+
this.#sessions.delete(key);
|
|
131
|
+
return this.session(key, request);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Why a cached session must be rebuilt before this turn, or undefined when it is current. */
|
|
135
|
+
#staleReason(
|
|
136
|
+
channelSession: ChannelSession,
|
|
137
|
+
request: TurnRequest,
|
|
138
|
+
): string | undefined {
|
|
139
|
+
if (channelSession.revisions !== revisionsKey(this.#factory.plan))
|
|
140
|
+
return "connectors changed";
|
|
141
|
+
if (
|
|
142
|
+
channelSession.skills !== skillsKey(this.#factory.skillsOf(request.agent))
|
|
143
|
+
)
|
|
144
|
+
return "skills changed";
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Drops the open session and everything held for the conversation, leaving its files. */
|
|
149
|
+
// pi-lens-ignore: mixed-async-styles — disposal failures of a dropped session are swallowed in a chain on purpose
|
|
150
|
+
async forget(channel: ChannelKey): Promise<void> {
|
|
151
|
+
const pending = this.#sessions.get(channel);
|
|
152
|
+
this.#sessions.delete(channel);
|
|
153
|
+
await pending
|
|
154
|
+
?.then(({ session }) => session.dispose())
|
|
155
|
+
.catch(() => undefined);
|
|
156
|
+
this.#gates.delete(channel);
|
|
157
|
+
this.#slots.delete(channel);
|
|
158
|
+
this.usage.delete(channel);
|
|
159
|
+
this.judged.delete(channel);
|
|
160
|
+
await this.#options.confirmations.save(channel, undefined);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Puts the agent's current prompt first in the system prompt's appended part before each run,
|
|
5
|
+
* keeping what earlier handlers appended, such as the owner memory section.
|
|
6
|
+
*/
|
|
7
|
+
export function agentPromptExtension(prompt: () => string): ExtensionFactory {
|
|
8
|
+
return (pi) => {
|
|
9
|
+
pi.on("before_agent_start", (event) => {
|
|
10
|
+
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
11
|
+
event.systemPromptOptions.appendSystemPrompt = appended
|
|
12
|
+
? `${prompt()}\n\n${appended}`
|
|
13
|
+
: prompt();
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
}
|