pi-roundtable 0.7.19 → 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 +140 -0
- package/README.md +72 -15
- package/README.zh-TW.md +65 -13
- package/docs/migrating-0.8.md +132 -0
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +853 -81
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/discord-adapter.test.ts +42 -0
- package/examples/discord-adapter.ts +26 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/echo-runtime.ts +1 -1
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.test.ts +72 -0
- package/examples/headless.ts +35 -0
- package/examples/http.test.ts +31 -1
- package/examples/http.ts +39 -0
- 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 +7 -3
- 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 +130 -5
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +28 -5
- package/src/cli/init.ts +9 -1
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +33 -2
- package/src/cli/report.ts +12 -3
- package/src/cli/templates.ts +46 -14
- 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/agent-team-fixture.ts +6 -0
- package/src/core/agents/team-turns.ts +10 -1
- package/src/core/builtin/agent-server.ts +38 -101
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +197 -43
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +120 -0
- package/src/core/config/config.ts +228 -94
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +81 -9
- package/src/core/contract/providers.ts +3 -2
- package/src/core/contract/runtime.ts +31 -11
- package/src/core/contract/surface.ts +35 -6
- package/src/core/conversations/conversation-registry.ts +56 -0
- package/src/core/conversations/conversation-store.ts +126 -0
- package/src/core/conversations/conversations-plugin.ts +29 -0
- package/src/core/define-roundtable.ts +157 -57
- 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-adapter.ts +9 -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 -86
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +10 -0
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +33 -2
- package/src/core/domain/progress.ts +10 -0
- package/src/core/events.ts +1 -0
- package/src/core/host.ts +32 -10
- package/src/core/http/listeners.ts +174 -21
- package/src/core/http/websocket.ts +316 -0
- package/src/core/i18n/cards.ts +11 -9
- 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/ops/error-reporter.ts +24 -12
- package/src/core/plugin.ts +62 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/registry/providers.ts +1 -1
- package/src/core/routing/channel-router.ts +226 -17
- package/src/core/routing/conversation-turns.ts +85 -1
- package/src/core/routing/surface-port.ts +35 -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 +32 -10
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/extensions/self-compact-guard.ts +11 -0
- package/src/core/runtime/pending-confirmation-store.ts +67 -4
- package/src/core/runtime/pi-agent-runtime.ts +143 -135
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +6 -6
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +205 -0
- 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/runtime/turn-progress.ts +116 -0
- package/src/core/services.ts +42 -2
- package/src/core/sessions.ts +30 -0
- package/src/core/shared/unix-server.ts +35 -5
- 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 +149 -38
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +460 -0
- package/src/core/testing/test-host.ts +100 -19
- 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 +10 -0
- package/src/index.ts +65 -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 +96 -21
- package/templates/.env.example +1 -1
- package/templates/adapters/web/.env.example +24 -0
- package/templates/adapters/web/README.md +22 -0
- package/templates/adapters/web/package.json.tmpl +26 -0
- package/templates/adapters/web/persona/assistant.md +2 -0
- package/templates/adapters/web/roundtable.config.ts +59 -0
- package/templates/package.json.tmpl +1 -0
- package/templates/plugins/self-compact.ts +11 -0
- package/templates/roundtable.config.ts +14 -2
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -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,
|
|
@@ -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,116 @@
|
|
|
1
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TurnProgress } from "../domain/progress.ts";
|
|
3
|
+
|
|
4
|
+
/** How long text is joined before it is sent, so a fast stream costs a few events a second. */
|
|
5
|
+
export const PROGRESS_INTERVAL_MS = 250;
|
|
6
|
+
/** The longest preview of a tool's arguments, ellipsis included. */
|
|
7
|
+
export const PROGRESS_PREVIEW_CHARS = 80;
|
|
8
|
+
|
|
9
|
+
/** One turn's progress as the runtime reports it; every method returns at once and never throws. */
|
|
10
|
+
export interface ProgressReporter {
|
|
11
|
+
text(delta: string): void;
|
|
12
|
+
toolStart(id: string, tool: string, args: unknown): void;
|
|
13
|
+
toolEnd(id: string, tool: string, isError: boolean): void;
|
|
14
|
+
/** Reports a session's event: its text deltas and its tools' starts and ends, never the thinking. */
|
|
15
|
+
observe(event: AgentSessionEvent): void;
|
|
16
|
+
/** Sends the text not sent yet; nothing is sent after it. */
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ProgressReporterOptions {
|
|
21
|
+
intervalMs?: number;
|
|
22
|
+
/** Runs `run` after `ms`; returns a cancel. Replaceable in tests. */
|
|
23
|
+
setTimer?: (run: () => void, ms: number) => () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const realTimer = (run: () => void, ms: number) => {
|
|
27
|
+
const timer = setTimeout(run, ms);
|
|
28
|
+
timer.unref?.();
|
|
29
|
+
return () => clearTimeout(timer);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** One line of a tool's arguments, cut to the preview length; undefined for none. */
|
|
33
|
+
export function toolPreview(args: unknown): string | undefined {
|
|
34
|
+
let text: string | undefined;
|
|
35
|
+
try {
|
|
36
|
+
text = args === undefined ? undefined : JSON.stringify(args);
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
if (!text || text === "{}" || text === "[]") return undefined;
|
|
41
|
+
const line = text.replace(/\s+/g, " ");
|
|
42
|
+
return line.length > PROGRESS_PREVIEW_CHARS
|
|
43
|
+
? `${line.slice(0, PROGRESS_PREVIEW_CHARS - 1)}…`
|
|
44
|
+
: line;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Turns a session's text deltas and tool events into `TurnProgress`. Text is joined and sent at
|
|
49
|
+
* most once per interval, and always before a tool event, so the order the turn wrote in holds.
|
|
50
|
+
* A sink that throws loses that event only.
|
|
51
|
+
*/
|
|
52
|
+
export function progressReporter(
|
|
53
|
+
sink: (event: TurnProgress) => void,
|
|
54
|
+
options: ProgressReporterOptions = {},
|
|
55
|
+
): ProgressReporter {
|
|
56
|
+
const intervalMs = options.intervalMs ?? PROGRESS_INTERVAL_MS;
|
|
57
|
+
const setTimer = options.setTimer ?? realTimer;
|
|
58
|
+
let buffered = "";
|
|
59
|
+
let cancel: (() => void) | undefined;
|
|
60
|
+
let closed = false;
|
|
61
|
+
const send = (event: TurnProgress) => {
|
|
62
|
+
try {
|
|
63
|
+
sink(event);
|
|
64
|
+
} catch {
|
|
65
|
+
// The surface's failure is its own; the turn goes on without that event.
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const flush = () => {
|
|
69
|
+
cancel?.();
|
|
70
|
+
cancel = undefined;
|
|
71
|
+
if (buffered === "") return;
|
|
72
|
+
const delta = buffered;
|
|
73
|
+
buffered = "";
|
|
74
|
+
send({ type: "text", delta });
|
|
75
|
+
};
|
|
76
|
+
const reporter: ProgressReporter = {
|
|
77
|
+
observe(event) {
|
|
78
|
+
if (
|
|
79
|
+
event.type === "message_update" &&
|
|
80
|
+
event.assistantMessageEvent.type === "text_delta"
|
|
81
|
+
)
|
|
82
|
+
reporter.text(event.assistantMessageEvent.delta);
|
|
83
|
+
else if (event.type === "tool_execution_start")
|
|
84
|
+
reporter.toolStart(event.toolCallId, event.toolName, event.args);
|
|
85
|
+
else if (event.type === "tool_execution_end")
|
|
86
|
+
reporter.toolEnd(event.toolCallId, event.toolName, event.isError);
|
|
87
|
+
},
|
|
88
|
+
text(delta) {
|
|
89
|
+
if (closed || delta === "") return;
|
|
90
|
+
buffered += delta;
|
|
91
|
+
cancel ??= setTimer(flush, intervalMs);
|
|
92
|
+
},
|
|
93
|
+
toolStart(id, tool, args) {
|
|
94
|
+
if (closed) return;
|
|
95
|
+
flush();
|
|
96
|
+
const preview = toolPreview(args);
|
|
97
|
+
send({
|
|
98
|
+
type: "tool_start",
|
|
99
|
+
id,
|
|
100
|
+
tool,
|
|
101
|
+
...(preview === undefined ? {} : { preview }),
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
toolEnd(id, tool, isError) {
|
|
105
|
+
if (closed) return;
|
|
106
|
+
flush();
|
|
107
|
+
send({ type: "tool_end", id, tool, ok: !isError });
|
|
108
|
+
},
|
|
109
|
+
close() {
|
|
110
|
+
if (closed) return;
|
|
111
|
+
flush();
|
|
112
|
+
closed = true;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
return reporter;
|
|
116
|
+
}
|
package/src/core/services.ts
CHANGED
|
@@ -1,11 +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";
|
|
10
|
+
import type { ConversationRegistry } from "./conversations/conversation-registry.ts";
|
|
7
11
|
import type { PendingConfirmation } from "./domain/conversation.ts";
|
|
8
12
|
import type { HttpRoute } from "./http/listeners.ts";
|
|
13
|
+
import type { IdentityService } from "./identity/identity-service.ts";
|
|
9
14
|
import type { ThinkingSetting } from "./models.ts";
|
|
10
15
|
import type {
|
|
11
16
|
DelegationJob,
|
|
@@ -35,6 +40,25 @@ import type { AgentTurnScope, ChannelKey } from "./sessions.ts";
|
|
|
35
40
|
// interface, so an object with the same methods satisfies it without being one of the built-in
|
|
36
41
|
// classes; a plugin replaces a built-in by providing its own under the same key.
|
|
37
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");
|
|
49
|
+
/**
|
|
50
|
+
* The runtime every conversation turn runs on: the agent server's, and each turn run through
|
|
51
|
+
* `context.turns`. The `runtime` provider slot's when a plugin fills it, Pi's otherwise. Provided
|
|
52
|
+
* by the `runtime` plugin, which every host registers, with or without the agent server.
|
|
53
|
+
*/
|
|
54
|
+
export const RUNTIME: ServiceKey<AgentRuntime> =
|
|
55
|
+
serviceKey<AgentRuntime>("roundtable.runtime");
|
|
56
|
+
/**
|
|
57
|
+
* The conversations run through `context.turns`: who each belongs to, its kind and visibility,
|
|
58
|
+
* and when it was last active, recorded at its first turn. Provided by the `conversations` plugin.
|
|
59
|
+
*/
|
|
60
|
+
export const CONVERSATIONS: ServiceKey<ConversationRegistry> =
|
|
61
|
+
serviceKey<ConversationRegistry>("roundtable.conversations");
|
|
38
62
|
/** The agent server: its team, its directory, the runtime that runs every agent turn. Provided by the agent-server plugin. */
|
|
39
63
|
export const AGENTS: ServiceKey<AgentServer> =
|
|
40
64
|
serviceKey<AgentServer>("roundtable.agents");
|
|
@@ -84,7 +108,7 @@ export interface AgentServer {
|
|
|
84
108
|
team: AgentTeam;
|
|
85
109
|
/** Read-only lookups of agents and groups; edits go through the team. */
|
|
86
110
|
directory: AgentDirectory;
|
|
87
|
-
/** The runtime every agent-server turn runs on: the `
|
|
111
|
+
/** The runtime every agent-server turn runs on: the same one `RUNTIME` provides. */
|
|
88
112
|
runtime: AgentRuntime;
|
|
89
113
|
/** Decides whether the owner's reply approves the actions the assistant held for them. */
|
|
90
114
|
approvals: {
|
|
@@ -164,6 +188,16 @@ export interface AgentTeam {
|
|
|
164
188
|
/** Scheduled turns, stored. A one-time schedule is deleted once it fires. */
|
|
165
189
|
export interface ScheduleStore {
|
|
166
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 }>;
|
|
167
201
|
get(id: number): Promise<Schedule | undefined>;
|
|
168
202
|
forChannel(channel: ChannelKey): Promise<Schedule[]>;
|
|
169
203
|
all(): Promise<Schedule[]>;
|
|
@@ -194,6 +228,12 @@ export interface BackgroundTurns {
|
|
|
194
228
|
firedAt: Date,
|
|
195
229
|
finding?: PrecheckFinding,
|
|
196
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>;
|
|
197
237
|
/** A delegated task's report, answered in its channel under the same rules as a schedule. */
|
|
198
238
|
runDelegated(job: DelegationJob, result: DelegationOutcome): Promise<void>;
|
|
199
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. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { rmSync } from "node:fs";
|
|
2
|
-
import type { Server } from "bun";
|
|
2
|
+
import type { Server, WebSocketHandler } from "bun";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Serves HTTP on a unix socket without Bun's 10-second idle timeout, which would cut long
|
|
@@ -9,18 +9,48 @@ import type { Server } from "bun";
|
|
|
9
9
|
*/
|
|
10
10
|
export function serveUnix(
|
|
11
11
|
socketPath: string,
|
|
12
|
-
fetch: (
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
fetch: (
|
|
13
|
+
request: Request,
|
|
14
|
+
server: Server<undefined>,
|
|
15
|
+
) => Response | Promise<Response>,
|
|
16
|
+
options?: { error?: (error: Error) => Response | Promise<Response> },
|
|
17
|
+
): Server<undefined>;
|
|
18
|
+
/**
|
|
19
|
+
* Serves HTTP and WebSockets on a unix socket: `websocket` serves the sockets `fetch` upgrades
|
|
20
|
+
* with `server.upgrade`, and `fetch` answers nothing for a request it upgraded.
|
|
21
|
+
*/
|
|
22
|
+
export function serveUnix<WebSocketData>(
|
|
23
|
+
socketPath: string,
|
|
24
|
+
fetch: (
|
|
25
|
+
request: Request,
|
|
26
|
+
server: Server<WebSocketData>,
|
|
27
|
+
) => Response | undefined | Promise<Response | undefined>,
|
|
28
|
+
options: {
|
|
29
|
+
error?: (error: Error) => Response | Promise<Response>;
|
|
30
|
+
websocket: WebSocketHandler<WebSocketData>;
|
|
31
|
+
},
|
|
32
|
+
): Server<WebSocketData>;
|
|
33
|
+
export function serveUnix<WebSocketData>(
|
|
34
|
+
socketPath: string,
|
|
35
|
+
fetch: (
|
|
36
|
+
request: Request,
|
|
37
|
+
server: Server<WebSocketData>,
|
|
38
|
+
) => Response | undefined | Promise<Response | undefined>,
|
|
39
|
+
options: {
|
|
40
|
+
error?: (error: Error) => Response | Promise<Response>;
|
|
41
|
+
websocket?: WebSocketHandler<WebSocketData>;
|
|
42
|
+
} = {},
|
|
43
|
+
): Server<WebSocketData> {
|
|
15
44
|
rmSync(socketPath, { force: true });
|
|
16
45
|
const serveOptions = {
|
|
17
46
|
unix: socketPath,
|
|
18
47
|
idleTimeout: 0,
|
|
19
48
|
fetch,
|
|
20
49
|
...(options.error ? { error: options.error } : {}),
|
|
50
|
+
...(options.websocket ? { websocket: options.websocket } : {}),
|
|
21
51
|
};
|
|
22
52
|
// SAFETY: these are Bun's unix-socket options; only `idleTimeout` is missing from its types.
|
|
23
53
|
return Bun.serve(
|
|
24
54
|
serveOptions as unknown as Parameters<typeof Bun.serve>[0],
|
|
25
|
-
) as Server<
|
|
55
|
+
) as unknown as Server<WebSocketData>;
|
|
26
56
|
}
|
package/src/core/speakers.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { freeze } from "./freeze.ts";
|
|
2
|
+
import {
|
|
3
|
+
rulesOfSpeakerMap,
|
|
4
|
+
speakerPolicyOf,
|
|
5
|
+
} from "./identity/access-policy.ts";
|
|
2
6
|
import type { OwnerIdentity } from "./identity.ts";
|
|
3
7
|
|
|
4
8
|
/** How much a speaker may do: `owner` above `admin` above `member`. */
|
|
@@ -12,9 +16,16 @@ export function tierAtLeast(tier: Tier, least: Tier): boolean {
|
|
|
12
16
|
|
|
13
17
|
/** A person whose message starts a turn, and the tier the policy gave them. */
|
|
14
18
|
export interface Speaker {
|
|
19
|
+
/** The id the surface knows them by: a Discord user id, a web chat's `oidc:<issuer>:<subject>`. */
|
|
15
20
|
id: string;
|
|
16
21
|
name: string;
|
|
17
22
|
tier: Tier;
|
|
23
|
+
/**
|
|
24
|
+
* The principal they are, whose memory, schedules, and conversations are theirs on every
|
|
25
|
+
* surface. For a person carried over from 0.8 it equals `id`. The identity service sets it;
|
|
26
|
+
* whoever starts a turn for someone else takes it from `IDENTITY.speakerFor`.
|
|
27
|
+
*/
|
|
28
|
+
principalId: string;
|
|
18
29
|
}
|
|
19
30
|
|
|
20
31
|
/**
|
|
@@ -59,7 +70,10 @@ export function attributed(speaker: Speaker, text: string): string {
|
|
|
59
70
|
return `(Message from ${speaker.name}, at the ${speaker.tier} tier.)\n\n${text}`;
|
|
60
71
|
}
|
|
61
72
|
|
|
62
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* What a surface reports about the author of a message; the policy decides the rest. The 0.8
|
|
75
|
+
* form of `ActorFacts` from the identity service, with the surface's own ids and role ids.
|
|
76
|
+
*/
|
|
63
77
|
export interface SpeakerFacts {
|
|
64
78
|
id: string;
|
|
65
79
|
name: string;
|
|
@@ -92,23 +106,11 @@ export interface SpeakerMap {
|
|
|
92
106
|
members?: TierMembers;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* The policy of an operator's map; the highest tier an author qualifies for wins. It evaluates
|
|
111
|
+
* the access rules the map means on one surface, so it decides as the identity service's rules
|
|
112
|
+
* do; each speaker's principal is their own id, as for a person carried over from 0.8.
|
|
113
|
+
*/
|
|
96
114
|
export function speakerPolicy(map: SpeakerMap): SpeakerPolicy {
|
|
97
|
-
|
|
98
|
-
tier !== undefined &&
|
|
99
|
-
(tier.everyone === true ||
|
|
100
|
-
tier.users?.includes(author.id) === true ||
|
|
101
|
-
(author.roleIds ?? []).some((role) => tier.roles?.includes(role)));
|
|
102
|
-
const tierOf = (author: SpeakerFacts): Tier | undefined => {
|
|
103
|
-
if (map.owners.includes(author.id)) return "owner";
|
|
104
|
-
if (holds(map.admins, author)) return "admin";
|
|
105
|
-
if (holds(map.members, author)) return "member";
|
|
106
|
-
return undefined;
|
|
107
|
-
};
|
|
108
|
-
return {
|
|
109
|
-
resolve(author) {
|
|
110
|
-
const tier = tierOf(author);
|
|
111
|
-
return tier && { id: author.id, name: author.name, tier };
|
|
112
|
-
},
|
|
113
|
-
};
|
|
115
|
+
return speakerPolicyOf(rulesOfSpeakerMap(map));
|
|
114
116
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { BackgroundRunsAs, BackgroundTurn } from "../contract/channels.ts";
|
|
2
|
+
import type { Schedule } from "../modules/schedules/schedule-store.ts";
|
|
3
|
+
|
|
4
|
+
/** A conversation port's `runsAs` for tests: every turn runs as its author, at its own tier. */
|
|
5
|
+
export async function runsAsAuthor(
|
|
6
|
+
turn: BackgroundTurn,
|
|
7
|
+
): Promise<BackgroundRunsAs> {
|
|
8
|
+
const { author, tier } = turn;
|
|
9
|
+
return {
|
|
10
|
+
speaker: {
|
|
11
|
+
id: author.id,
|
|
12
|
+
name: author.name,
|
|
13
|
+
tier,
|
|
14
|
+
principalId: author.principalId,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A scheduled runner's `runsAs` for tests: every schedule runs as its creator, at the tier it was set at. */
|
|
20
|
+
export async function runsAsCreator(
|
|
21
|
+
schedule: Schedule,
|
|
22
|
+
): Promise<BackgroundRunsAs> {
|
|
23
|
+
return {
|
|
24
|
+
speaker: {
|
|
25
|
+
id: schedule.createdById,
|
|
26
|
+
name: schedule.createdByName,
|
|
27
|
+
tier: schedule.createdTier,
|
|
28
|
+
principalId: schedule.createdById,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { SQL } from "bun";
|
|
4
|
+
import { testDatabaseUrl } from "./database.ts";
|
|
5
|
+
|
|
6
|
+
/** A scratch database on the test server; drop it when the test ends. */
|
|
7
|
+
export interface ScratchDatabase {
|
|
8
|
+
url: string;
|
|
9
|
+
sql: SQL;
|
|
10
|
+
drop(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A new, empty database on the test server, or one loaded from a recorded fixture such as
|
|
15
|
+
* `"0.8.0"` (src/core/testing/fixtures/db-<version>.sql, written by scripts/fixture-db.ts). It
|
|
16
|
+
* needs PostgreSQL: run it under `describeDb`.
|
|
17
|
+
*/
|
|
18
|
+
export async function scratchDatabase(
|
|
19
|
+
fixture?: string,
|
|
20
|
+
): Promise<ScratchDatabase> {
|
|
21
|
+
const name = `roundtable_scratch_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
22
|
+
let url: URL;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(testDatabaseUrl);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`ROUNDTABLE_TEST_DATABASE_URL is not a postgres url: ${testDatabaseUrl}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
url.pathname = `/${name}`;
|
|
31
|
+
const server = new SQL(testDatabaseUrl, { max: 1 });
|
|
32
|
+
await server.unsafe(`CREATE DATABASE ${name}`);
|
|
33
|
+
await server.close();
|
|
34
|
+
const sql = new SQL(url.href, { max: 4 });
|
|
35
|
+
if (fixture)
|
|
36
|
+
await sql.unsafe(
|
|
37
|
+
readFileSync(
|
|
38
|
+
join(import.meta.dir, "fixtures", `db-${fixture}.sql`),
|
|
39
|
+
"utf8",
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
return {
|
|
43
|
+
url: url.href,
|
|
44
|
+
sql,
|
|
45
|
+
drop: async () => {
|
|
46
|
+
await sql.close();
|
|
47
|
+
const admin = new SQL(testDatabaseUrl, { max: 1 });
|
|
48
|
+
try {
|
|
49
|
+
await admin.unsafe(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
|
|
50
|
+
} finally {
|
|
51
|
+
await admin.close();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|