pi-roundtable 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +618 -86
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +150 -86
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +87 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +82 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +42 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/drain.ts +56 -5
- package/src/core/errors.ts +13 -0
- package/src/core/host-background.ts +56 -0
- package/src/core/host.ts +52 -68
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/i18n/conversation.ts +2 -0
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +56 -4
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-queue.ts +18 -1
- package/src/core/routing/channel-router.ts +274 -21
- package/src/core/routing/conversation-turns.ts +45 -10
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/abort-guard.ts +58 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +139 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +174 -190
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +51 -5
- package/src/core/runtime/runtime-types.ts +48 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +21 -3
- package/src/core/runtime/thinking-level.ts +47 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/runtime/turn-wrapup.ts +63 -0
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +48 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -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,
|
|
@@ -24,13 +33,22 @@ export class SteerableRun {
|
|
|
24
33
|
readonly #canSteer: () => boolean;
|
|
25
34
|
/** Steered prompts in arrival order, each with its images. */
|
|
26
35
|
readonly #steers: { text: string; images: PromptImages }[] = [];
|
|
36
|
+
readonly #abort: () => void;
|
|
27
37
|
#stopped = false;
|
|
28
38
|
#done = false;
|
|
29
39
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
40
|
+
/**
|
|
41
|
+
* `canSteer` is asked at each steer: the turn is the owner's and holds no actions. `abort`
|
|
42
|
+
* ends the session's run when the turn is stopped; by default the session's own abort.
|
|
43
|
+
*/
|
|
44
|
+
constructor(
|
|
45
|
+
session: SteeringSession,
|
|
46
|
+
canSteer: () => boolean,
|
|
47
|
+
abort: () => void = () => void session.abort(),
|
|
48
|
+
) {
|
|
32
49
|
this.#session = session;
|
|
33
50
|
this.#canSteer = canSteer;
|
|
51
|
+
this.#abort = abort;
|
|
34
52
|
}
|
|
35
53
|
|
|
36
54
|
get stopped(): boolean {
|
|
@@ -97,7 +115,7 @@ export class SteerableRun {
|
|
|
97
115
|
if (this.#done) return false;
|
|
98
116
|
this.#stopped = true;
|
|
99
117
|
this.#session.clearQueue();
|
|
100
|
-
|
|
118
|
+
this.#abort();
|
|
101
119
|
return true;
|
|
102
120
|
}
|
|
103
121
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentSession,
|
|
3
|
+
ModelRuntime,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
6
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
7
|
+
import { AUTO_THINKING, type ThinkingLevel } from "../models.ts";
|
|
8
|
+
import {
|
|
9
|
+
type AgentModelSettings,
|
|
10
|
+
useChosenAgentModel,
|
|
11
|
+
} from "./bridge-guard.ts";
|
|
12
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
13
|
+
import { lastReply } from "./turn-answer.ts";
|
|
14
|
+
|
|
15
|
+
/** Uses the checked model snapshot; auto thinking keeps the last pick while the judge is unsure. */
|
|
16
|
+
export async function thinkingLevel(
|
|
17
|
+
parts: {
|
|
18
|
+
modelRuntime: ModelRuntime;
|
|
19
|
+
effort: PiAgentRuntimeOptions["effort"];
|
|
20
|
+
/** The level last judged for each conversation. */
|
|
21
|
+
judged: Map<ChannelKey, ThinkingLevel>;
|
|
22
|
+
},
|
|
23
|
+
key: ChannelKey,
|
|
24
|
+
session: AgentSession,
|
|
25
|
+
request: TurnRequest,
|
|
26
|
+
agentModel?: AgentModelSettings,
|
|
27
|
+
): Promise<ThinkingLevel> {
|
|
28
|
+
const { modelRuntime, effort, judged } = parts;
|
|
29
|
+
const setting = request.agent
|
|
30
|
+
? await useChosenAgentModel(
|
|
31
|
+
session,
|
|
32
|
+
modelRuntime,
|
|
33
|
+
request.agent.name,
|
|
34
|
+
agentModel,
|
|
35
|
+
)
|
|
36
|
+
: AUTO_THINKING;
|
|
37
|
+
if (setting !== AUTO_THINKING) {
|
|
38
|
+
judged.delete(key);
|
|
39
|
+
return setting;
|
|
40
|
+
}
|
|
41
|
+
const level = await effort.judge(request.text, {
|
|
42
|
+
reply: lastReply(session.messages),
|
|
43
|
+
level: judged.get(key),
|
|
44
|
+
});
|
|
45
|
+
judged.set(key, level);
|
|
46
|
+
return level;
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionFactory,
|
|
5
|
+
ToolDefinition,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
/** The ids of the tool calls whose code is running, outermost first. */
|
|
9
|
+
const calls = new AsyncLocalStorage<readonly string[]>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The ids of the tool calls the code asking runs within, outermost first: the call of a session
|
|
13
|
+
* part's tool, and the calls it runs within in turn, such as a worker's call inside the call that
|
|
14
|
+
* started the worker. Empty outside every such call.
|
|
15
|
+
*/
|
|
16
|
+
export function runningCalls(): readonly string[] {
|
|
17
|
+
return calls.getStore() ?? [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A session part's extension whose tools each run within their call's id, so what a call starts,
|
|
22
|
+
* a task's worker among them, knows which call it serves rather than every call running at once.
|
|
23
|
+
*/
|
|
24
|
+
export function scopedCalls(factory: ExtensionFactory): ExtensionFactory {
|
|
25
|
+
return (pi) =>
|
|
26
|
+
factory(
|
|
27
|
+
new Proxy(pi, {
|
|
28
|
+
get(target, key) {
|
|
29
|
+
if (key === "registerTool")
|
|
30
|
+
return (tool: ToolDefinition) =>
|
|
31
|
+
target.registerTool({
|
|
32
|
+
...tool,
|
|
33
|
+
execute: (toolCallId, ...rest) =>
|
|
34
|
+
calls.run([...runningCalls(), toolCallId], () =>
|
|
35
|
+
tool.execute(toolCallId, ...rest),
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
const value: unknown = Reflect.get(target, key, target);
|
|
39
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
40
|
+
},
|
|
41
|
+
}) satisfies ExtensionAPI,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { TurnResult } from "../domain/conversation.ts";
|
|
1
|
+
import type { TranscriptEntry, TurnResult } from "../domain/conversation.ts";
|
|
2
2
|
import { AgentRunError } from "../domain/errors.ts";
|
|
3
3
|
import { hasReplyFiles } from "../reply-files.ts";
|
|
4
4
|
import { lastAssistant, textOf } from "../shared/session-messages.ts";
|
|
5
|
+
import { TRANSCRIPT_ENTRY_CHARS } from "./runtime-types.ts";
|
|
5
6
|
|
|
6
7
|
interface MessagePart {
|
|
7
8
|
type?: unknown;
|
|
@@ -20,6 +21,34 @@ type Assistant = Message & {
|
|
|
20
21
|
const isAssistant = (message: Message): message is Assistant =>
|
|
21
22
|
message.role === "assistant";
|
|
22
23
|
|
|
24
|
+
/** The answer to a turn sent without a speaker: refused, never run as the owner's by default. */
|
|
25
|
+
export function unspokenTurn(): TurnResult {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: new AgentRunError(
|
|
29
|
+
"a turn needs a speaker: pass TurnRequest.speaker, the person the turn is for; for a turn started on someone's behalf, get theirs from IDENTITY.speakerFor",
|
|
30
|
+
),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The last `limit` user and assistant messages as transcript entries, each cut to its first characters. */
|
|
35
|
+
export function transcriptOf(
|
|
36
|
+
messages: readonly (Message & { content?: unknown })[],
|
|
37
|
+
limit: number,
|
|
38
|
+
): TranscriptEntry[] {
|
|
39
|
+
const entries: TranscriptEntry[] = [];
|
|
40
|
+
for (const message of messages) {
|
|
41
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
42
|
+
const text = textOf(message.content).trim();
|
|
43
|
+
if (text)
|
|
44
|
+
entries.push({
|
|
45
|
+
role: message.role,
|
|
46
|
+
text: text.slice(0, TRANSCRIPT_ENTRY_CHARS),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return entries.slice(-limit);
|
|
50
|
+
}
|
|
51
|
+
|
|
23
52
|
/** The session's last answer, which tells the judge what a short follow-up continues. */
|
|
24
53
|
export function lastReply(messages: readonly Message[]): string | undefined {
|
|
25
54
|
const last = messages.findLast(isAssistant);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ChannelKey } from "../domain/conversation.ts";
|
|
3
|
+
import type { TurnRequest } from "../domain/ports.ts";
|
|
4
|
+
import type { ConfirmationGate } from "./extensions/confirmation-gate.ts";
|
|
5
|
+
import type { PiAgentRuntimeOptions } from "./runtime-types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a turn leaves behind when it ends, whatever way it ended: the gate stops counting the turn,
|
|
9
|
+
* the conversation's context usage and held actions are recorded, and the turn is logged. A
|
|
10
|
+
* session disposed of under a turn that would not stop has no usage to read.
|
|
11
|
+
*/
|
|
12
|
+
export async function wrapUpTurn(parts: {
|
|
13
|
+
key: ChannelKey;
|
|
14
|
+
request: TurnRequest;
|
|
15
|
+
session: AgentSession;
|
|
16
|
+
gate: ConfirmationGate;
|
|
17
|
+
toolCalls: readonly string[];
|
|
18
|
+
options: Pick<PiAgentRuntimeOptions, "confirmations" | "logger">;
|
|
19
|
+
recordUsage: (usage: {
|
|
20
|
+
tokens: number | null;
|
|
21
|
+
contextWindow: number;
|
|
22
|
+
}) => void;
|
|
23
|
+
}): Promise<void> {
|
|
24
|
+
const { key, request, session, gate, toolCalls, options, recordUsage } =
|
|
25
|
+
parts;
|
|
26
|
+
const { logger } = options;
|
|
27
|
+
gate.endTurn();
|
|
28
|
+
try {
|
|
29
|
+
const usage = session.getContextUsage();
|
|
30
|
+
if (usage)
|
|
31
|
+
recordUsage({
|
|
32
|
+
tokens: usage.tokens,
|
|
33
|
+
contextWindow: usage.contextWindow,
|
|
34
|
+
});
|
|
35
|
+
} catch (error) {
|
|
36
|
+
logger.warn({ channel: key, err: error }, "context usage not read");
|
|
37
|
+
}
|
|
38
|
+
const held = gate.pending();
|
|
39
|
+
await options.confirmations
|
|
40
|
+
.save(key, held)
|
|
41
|
+
// pi-lens-ignore: no-unknown-parameters — a rejection reason is unknown; it only reaches the logger
|
|
42
|
+
.catch((error: unknown) =>
|
|
43
|
+
logger.error({ channel: key, err: error }, "held actions not saved"),
|
|
44
|
+
);
|
|
45
|
+
if (held)
|
|
46
|
+
logger.info(
|
|
47
|
+
{ channel: key, held: held.calls.map((call) => call.action) },
|
|
48
|
+
"actions held for confirmation",
|
|
49
|
+
);
|
|
50
|
+
logger.info(
|
|
51
|
+
{
|
|
52
|
+
channel: key,
|
|
53
|
+
selection: request.selection.id,
|
|
54
|
+
toolCalls,
|
|
55
|
+
thinking: session.thinkingLevel,
|
|
56
|
+
// pi-lens-ignore: no-conditional-empty-object-spread — owner turns keep their log line without a model key
|
|
57
|
+
...(request.agent
|
|
58
|
+
? { model: `${session.model?.provider}/${session.model?.id}` }
|
|
59
|
+
: {}),
|
|
60
|
+
},
|
|
61
|
+
"turn finished",
|
|
62
|
+
);
|
|
63
|
+
}
|
package/src/core/services.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { Agent, AgentGroup } from "./agents/agent-rules.ts";
|
|
2
2
|
import type { AvatarMode } from "./agents/agent-tools.ts";
|
|
3
3
|
import type { TeamStatus } from "./agents/team-status.ts";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
BackgroundRunsAs,
|
|
6
|
+
ScheduledOutcome,
|
|
7
|
+
} from "./contract/channels.ts";
|
|
5
8
|
import type { AgentRuntime } from "./contract/runtime.ts";
|
|
6
9
|
import { type ServiceKey, serviceKey } from "./contract/services.ts";
|
|
7
10
|
import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
|
|
8
11
|
import type { PendingConfirmation } from "./domain/conversation.ts";
|
|
9
12
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
13
|
+
import type { IdentityService } from "./identity/identity-service.ts";
|
|
10
14
|
import type { ThinkingSetting } from "./models.ts";
|
|
11
15
|
import type {
|
|
12
16
|
DelegationJob,
|
|
@@ -36,6 +40,12 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
|
|
|
36
40
|
// interface, so an object with the same methods satisfies it without being one of the built-in
|
|
37
41
|
// classes; a plugin replaces a built-in by providing its own under the same key.
|
|
38
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Who the host serves: its principals, the identities linked to each, and their lasting roles.
|
|
45
|
+
* Provided by the `identity` plugin, which every host registers first, so every plugin may read it.
|
|
46
|
+
*/
|
|
47
|
+
export const IDENTITY: ServiceKey<IdentityService> =
|
|
48
|
+
serviceKey<IdentityService>("roundtable.identity");
|
|
39
49
|
/**
|
|
40
50
|
* The runtime every conversation turn runs on: the agent server's, and each turn run through
|
|
41
51
|
* `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
|
|
@@ -178,6 +188,16 @@ export interface AgentTeam {
|
|
|
178
188
|
/** Scheduled turns, stored. A one-time schedule is deleted once it fires. */
|
|
179
189
|
export interface ScheduleStore {
|
|
180
190
|
create(schedule: NewSchedule): Promise<Schedule>;
|
|
191
|
+
/**
|
|
192
|
+
* Creates a schedule unless its creator already has `limit.max` of its target's schedules,
|
|
193
|
+
* counting those whose creator id is one of `limit.creators`; resolves the count reached instead.
|
|
194
|
+
* Two calls for one creator never count at once, so creates at once in many conversations stay
|
|
195
|
+
* within the limit.
|
|
196
|
+
*/
|
|
197
|
+
createWithin(
|
|
198
|
+
schedule: NewSchedule,
|
|
199
|
+
limit: { creators: readonly string[]; max: number },
|
|
200
|
+
): Promise<{ created: Schedule } | { reached: number }>;
|
|
181
201
|
get(id: number): Promise<Schedule | undefined>;
|
|
182
202
|
forChannel(channel: ChannelKey): Promise<Schedule[]>;
|
|
183
203
|
all(): Promise<Schedule[]>;
|
|
@@ -208,6 +228,12 @@ export interface BackgroundTurns {
|
|
|
208
228
|
firedAt: Date,
|
|
209
229
|
finding?: PrecheckFinding,
|
|
210
230
|
): Promise<ScheduledOutcome>;
|
|
231
|
+
/**
|
|
232
|
+
* Who a due schedule's turn would run as now, checked as the turn is: asked before its
|
|
233
|
+
* precheck, which runs only for a creator who may run the turn, at the tier it would run at.
|
|
234
|
+
* Throws when the identity service cannot tell.
|
|
235
|
+
*/
|
|
236
|
+
runsAs(schedule: Schedule): Promise<BackgroundRunsAs>;
|
|
211
237
|
/** A delegated task's report, answered in its channel under the same rules as a schedule. */
|
|
212
238
|
runDelegated(job: DelegationJob, result: DelegationOutcome): Promise<void>;
|
|
213
239
|
/** The process's own logged error, reported to an agent in its channel as a report turn. */
|
package/src/core/sessions.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { PluginError } from "./errors.ts";
|
|
3
|
+
import type { Principal } from "./identity/principal-store.ts";
|
|
4
|
+
import type { OwnerIdentity } from "./identity.ts";
|
|
3
5
|
import type { Speaker } from "./speakers.ts";
|
|
4
6
|
|
|
5
7
|
/** `<surface>:<channel id>`, for example `discord:1234`. A DM is one channel. */
|
|
@@ -41,6 +43,15 @@ export interface TransientTask {
|
|
|
41
43
|
onFinished?(toolCalls: readonly string[]): void;
|
|
42
44
|
}
|
|
43
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Whom a session's conversation serves, fixed when the session is made: `private` to one
|
|
48
|
+
* principal, whose name and pronouns are known when the host has a record of them, or `shared`
|
|
49
|
+
* by whoever its claim admits, as an agent's session always is.
|
|
50
|
+
*/
|
|
51
|
+
export type SessionConversation =
|
|
52
|
+
| { visibility: "private"; principalId: string; principal?: Principal }
|
|
53
|
+
| { visibility: "shared" };
|
|
54
|
+
|
|
44
55
|
/** What one Pi session is for, as a session tool's factory sees it. */
|
|
45
56
|
export interface SessionContext {
|
|
46
57
|
/** "agent" for an agent's session; otherwise the kind of conversation, which the claim that owns the channel names. */
|
|
@@ -53,6 +64,25 @@ export interface SessionContext {
|
|
|
53
64
|
agent?: AgentTurnScope;
|
|
54
65
|
/** Wraps a compactor so the core's compaction tiers decide when it may answer. */
|
|
55
66
|
compaction: { wrap(compactor: ExtensionFactory): ExtensionFactory };
|
|
67
|
+
/**
|
|
68
|
+
* Whom the conversation serves, as `TurnRequest.conversation` or the host's record says when the
|
|
69
|
+
* session is made; shared when neither says. A private conversation's tools serve its person:
|
|
70
|
+
* their memory, their notices. A shared one's serve each turn's speaker.
|
|
71
|
+
*/
|
|
72
|
+
conversation: SessionConversation;
|
|
73
|
+
/**
|
|
74
|
+
* Whom the session's tool descriptions address, fixed when it is made: a private conversation's
|
|
75
|
+
* person by their name and pronouns, the primary owner exactly as 0.8 wrote them, and
|
|
76
|
+
* `THE_SPEAKER` in a shared conversation, where each turn's prompt says who speaks.
|
|
77
|
+
*/
|
|
78
|
+
addressee: OwnerIdentity;
|
|
79
|
+
/**
|
|
80
|
+
* Whose memory the session's turns may read: `"speaker"` (the default), as `conversation` and
|
|
81
|
+
* each turn's speaker decide, or `"none"`, for a persona that declares it; then no memory loads.
|
|
82
|
+
* While the running turn is one the agent server keeps out of memory (`AgentSessions.memory`),
|
|
83
|
+
* it reads `"none"` for that turn alone.
|
|
84
|
+
*/
|
|
85
|
+
memory: "speaker" | "none";
|
|
56
86
|
/** The person the session's running turn is for; undefined between turns. */
|
|
57
87
|
speaker(): Speaker | undefined;
|
|
58
88
|
/** Runs a task beside this session, under its confirmation gate. */
|