pi-roundtable 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/README.md +35 -11
- package/README.zh-TW.md +27 -10
- package/docs/migration-0.9.md +247 -0
- package/docs/plugins.md +606 -78
- package/examples/channel-context.test.ts +88 -0
- package/examples/channel-context.ts +62 -0
- package/examples/echo-runtime.test.ts +1 -0
- package/examples/fake-surface.test.ts +69 -6
- package/examples/fake-surface.ts +73 -7
- package/examples/headless.ts +5 -1
- package/examples/prechecks.test.ts +1 -0
- package/examples/principals.test.ts +60 -0
- package/examples/principals.ts +48 -0
- package/examples/prompt.test.ts +1 -1
- package/examples/study-room.test.ts +29 -1
- package/examples/study-room.ts +16 -19
- package/examples/support-desk.test.ts +3 -1
- package/examples/tools.test.ts +6 -1
- package/package.json +5 -2
- package/src/cli/checks/database.ts +11 -0
- package/src/cli/checks/identity.ts +225 -0
- package/src/cli/checks/model.ts +21 -3
- package/src/cli/cli.ts +115 -1
- package/src/cli/config-edit.ts +83 -13
- package/src/cli/doctor.ts +7 -0
- package/src/cli/line-diff.ts +72 -0
- package/src/cli/principal.ts +412 -0
- package/src/cli/project.ts +5 -0
- package/src/cli/report.ts +12 -3
- package/src/cli/upgrade-rewrite.ts +102 -0
- package/src/cli/upgrade-source.ts +430 -0
- package/src/cli/upgrade.ts +176 -0
- package/src/core/agents/agent-claim.ts +56 -34
- package/src/core/agents/team-turns.ts +8 -4
- package/src/core/builtin/agent-server.ts +9 -7
- package/src/core/builtin/discord-admin.ts +4 -1
- package/src/core/builtin/discord.ts +71 -13
- package/src/core/builtin/modules.ts +140 -85
- package/src/core/builtin/stores.ts +40 -21
- package/src/core/config/access.ts +229 -0
- package/src/core/config/adapters.ts +64 -1
- package/src/core/config/config.ts +78 -41
- package/src/core/contract/channel-context.ts +53 -0
- package/src/core/contract/channels.ts +77 -9
- package/src/core/contract/runtime.ts +23 -7
- package/src/core/contract/surface.ts +22 -6
- package/src/core/conversations/conversation-registry.ts +14 -0
- package/src/core/conversations/conversation-store.ts +13 -0
- package/src/core/define-roundtable.ts +41 -11
- package/src/core/discord/agent-panel.ts +2 -2
- package/src/core/discord/card-audience.ts +148 -0
- package/src/core/discord/channel-context.ts +348 -0
- package/src/core/discord/connection.ts +15 -3
- package/src/core/discord/direct-channel.ts +54 -0
- package/src/core/discord/discord-owners.ts +133 -0
- package/src/core/discord/discord-surface.ts +69 -14
- package/src/core/discord/fresh-marker.ts +24 -0
- package/src/core/discord/inbound-message.ts +21 -7
- package/src/core/discord/interaction-module.ts +11 -1
- package/src/core/discord/owner-cards-fixture.ts +80 -0
- package/src/core/discord/owner-cards.ts +58 -97
- package/src/core/discord/owner-command.ts +27 -13
- package/src/core/discord/stop-button.ts +4 -4
- package/src/core/domain/conversation.ts +7 -2
- package/src/core/domain/errors.ts +5 -0
- package/src/core/domain/ports.ts +27 -2
- package/src/core/host.ts +27 -8
- package/src/core/i18n/cards.ts +8 -4
- package/src/core/identity/access-policy.ts +227 -0
- package/src/core/identity/actor-facts.ts +65 -0
- package/src/core/identity/caching-principal-store.ts +111 -0
- package/src/core/identity/config-owner.ts +145 -0
- package/src/core/identity/contact.ts +27 -0
- package/src/core/identity/identity-plugin.ts +56 -0
- package/src/core/identity/identity-schema.ts +282 -0
- package/src/core/identity/identity-service.ts +489 -0
- package/src/core/identity/identity-view.ts +77 -0
- package/src/core/identity/plugin-identities.ts +109 -0
- package/src/core/identity/principal-reads.ts +38 -0
- package/src/core/identity/principal-store.ts +478 -0
- package/src/core/identity/principal-tables.ts +53 -0
- package/src/core/identity/seen-throttle.ts +57 -0
- package/src/core/identity/ulid.ts +36 -0
- package/src/core/identity.ts +34 -5
- package/src/core/interactions/prompts.ts +127 -0
- package/src/core/modules/background/background-turns.ts +56 -17
- package/src/core/modules/background/personal-target.ts +50 -0
- package/src/core/modules/delegation/delegate.ts +28 -14
- package/src/core/modules/delegation/delegator.ts +23 -7
- package/src/core/modules/memory/owner-memory.ts +123 -50
- package/src/core/modules/notify/notify.ts +53 -13
- package/src/core/modules/notify/session-notify.ts +110 -0
- package/src/core/modules/schedules/prechecks.ts +8 -2
- package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
- package/src/core/modules/schedules/schedule-store.ts +51 -20
- package/src/core/modules/schedules/schedule-tools.ts +122 -12
- package/src/core/modules/schedules/scheduler.ts +43 -2
- package/src/core/modules/schedules/schedules.ts +43 -14
- package/src/core/plugin.ts +48 -0
- package/src/core/presence/direct-channels.ts +104 -0
- package/src/core/registry/contributions.ts +63 -3
- package/src/core/routing/channel-router.ts +218 -17
- package/src/core/routing/conversation-turns.ts +38 -8
- package/src/core/routing/surface-port.ts +32 -2
- package/src/core/routing/system-turns.ts +29 -0
- package/src/core/runtime/bridge-guard.ts +225 -0
- package/src/core/runtime/compaction-tiers.ts +5 -1
- package/src/core/runtime/conversation-sessions.ts +124 -12
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
- package/src/core/runtime/extensions/private-memory.ts +447 -0
- package/src/core/runtime/pending-confirmation-store.ts +30 -5
- package/src/core/runtime/pi-agent-runtime.ts +135 -134
- package/src/core/runtime/preflight-tools.ts +47 -0
- package/src/core/runtime/prompt-slot.ts +4 -4
- package/src/core/runtime/reader-history.ts +129 -0
- package/src/core/runtime/runtime-plugin.ts +50 -5
- package/src/core/runtime/runtime-types.ts +40 -12
- package/src/core/runtime/session-conversation.ts +143 -0
- package/src/core/runtime/session-factory.ts +115 -15
- package/src/core/runtime/session-scope.ts +78 -0
- package/src/core/runtime/steerable-run.ts +9 -0
- package/src/core/runtime/tool-call-scope.ts +43 -0
- package/src/core/runtime/turn-answer.ts +30 -1
- package/src/core/services.ts +27 -1
- package/src/core/sessions.ts +30 -0
- package/src/core/speakers.ts +21 -19
- package/src/core/testing/background.ts +31 -0
- package/src/core/testing/fixture-database.ts +55 -0
- package/src/core/testing/map-identity.ts +76 -0
- package/src/core/testing/module-sessions.ts +134 -0
- package/src/core/testing/modules.ts +108 -21
- package/src/core/testing/owner.ts +17 -0
- package/src/core/testing/surface-contract.ts +118 -41
- package/src/core/testing/test-host.ts +79 -16
- package/src/core/testing/tool-set.ts +32 -0
- package/src/core/tool-set.snapshot.json +4 -4
- package/src/core/tool-tiers.ts +57 -3
- package/src/discord/index.ts +8 -0
- package/src/index.ts +47 -8
- package/src/kit/compaction.ts +13 -0
- package/src/kit/index.ts +10 -0
- package/src/kit/support.ts +1 -1
- package/src/testing.ts +55 -16
- package/templates/.env.example +1 -1
- package/templates/adapters/web/README.md +7 -0
- package/templates/adapters/web/roundtable.config.ts +14 -10
- package/templates/roundtable.config.ts +12 -1
- package/src/core/domain/owner-prompts.ts +0 -45
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import { messages } from "../../i18n/index.ts";
|
|
4
|
-
import { type OwnerIdentity
|
|
4
|
+
import { addresseeWords, type OwnerIdentity } from "../../identity.ts";
|
|
5
5
|
import type { MemoryStore } from "../../services.ts";
|
|
6
6
|
import { toolError, toolText } from "../../shared/tool-result.ts";
|
|
7
|
-
import type { Speaker } from "../../speakers.ts";
|
|
8
7
|
import { timeZone, zonedToday } from "../../time.ts";
|
|
9
8
|
import type { Memory, PromptMemory } from "./owner-memory-store.ts";
|
|
10
9
|
|
|
@@ -17,8 +16,8 @@ function line(memory: Memory): string {
|
|
|
17
16
|
export function ownerMemorySection(
|
|
18
17
|
memory: PromptMemory,
|
|
19
18
|
today: string,
|
|
20
|
-
/**
|
|
21
|
-
|
|
19
|
+
/** The name of whose memory it is, when not the primary owner's. */
|
|
20
|
+
name?: string,
|
|
22
21
|
): string {
|
|
23
22
|
const core =
|
|
24
23
|
memory.core.length > 0
|
|
@@ -27,7 +26,7 @@ export function ownerMemorySection(
|
|
|
27
26
|
const events =
|
|
28
27
|
memory.events.length > 0 ? memory.events.map(line).join("\n") : "(none)";
|
|
29
28
|
return [
|
|
30
|
-
|
|
29
|
+
name === undefined ? "## Owner memory" : `## Memory of ${name}`,
|
|
31
30
|
`Today in ${messages().zoneName(timeZone())} is ${today}. What is stored right now, including anything you added earlier in this conversation; each appears once.`,
|
|
32
31
|
`### Core facts\n${core}`,
|
|
33
32
|
`### Upcoming events\n${events}`,
|
|
@@ -35,42 +34,92 @@ export function ownerMemorySection(
|
|
|
35
34
|
].join("\n\n");
|
|
36
35
|
}
|
|
37
36
|
|
|
37
|
+
/** Whose memory a session's turns read, and how its tools name them. */
|
|
38
|
+
export interface MemoryFor {
|
|
39
|
+
/** The primary owner's principal, whose memory keeps 0.8's "Owner memory" heading. */
|
|
40
|
+
ownerId: string;
|
|
41
|
+
/** Whom the tool descriptions address: a private conversation's person, or `THE_SPEAKER`. */
|
|
42
|
+
addressee: OwnerIdentity;
|
|
43
|
+
/** Whether the tools tell the model that each speaker has a memory of their own, as in a shared conversation. */
|
|
44
|
+
describesSpeakers: boolean;
|
|
45
|
+
/** Whose memory the running turn reads and changes, and their name; undefined when it is no one's. */
|
|
46
|
+
whose(): { principalId: string; name: string } | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The memory tools, whose results hold one person's memory. */
|
|
50
|
+
export const MEMORY_TOOLS = [
|
|
51
|
+
"memory_add",
|
|
52
|
+
"memory_search",
|
|
53
|
+
"memory_remove",
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
/** What a memory tool's result records in the session: whose memory it holds. */
|
|
57
|
+
interface PrivateMemory {
|
|
58
|
+
privateTo: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A memory tool's answer from the principal's memory, which records whose it is. */
|
|
62
|
+
function privateText(text: string, principalId: string) {
|
|
63
|
+
const details: PrivateMemory = { privateTo: principalId };
|
|
64
|
+
return { ...toolText(text), details };
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
68
|
+
* The call's answer, or, when it fails, such as with the store unreachable, its error recording
|
|
69
|
+
* whose memory the call was for: its arguments are what the model wrote from that memory.
|
|
70
|
+
*/
|
|
71
|
+
async function ownAnswer<T>(
|
|
72
|
+
principalId: string,
|
|
73
|
+
answer: () => Promise<T>,
|
|
74
|
+
): Promise<T | (ReturnType<typeof toolError> & { details: PrivateMemory })> {
|
|
75
|
+
try {
|
|
76
|
+
return await answer();
|
|
77
|
+
} catch (error) {
|
|
78
|
+
const details: PrivateMemory = { privateTo: principalId };
|
|
79
|
+
return {
|
|
80
|
+
...toolError(error instanceof Error ? error.message : String(error)),
|
|
81
|
+
details,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What the memory tools answer in a turn that reads no one's memory, such as the host's own report. */
|
|
87
|
+
const NO_ONE =
|
|
88
|
+
"This turn reads no one's memory, so there is nothing to remember, search, or forget in it.";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Adds the core facts and upcoming events of whose memory the turn reads to every run, and
|
|
92
|
+
* registers memory_add, memory_search, and memory_remove over it. The section goes into
|
|
93
|
+
* appendSystemPrompt because claude-bridge forwards only the prompt's option sections to Claude
|
|
94
|
+
* Code, not a replaced system prompt.
|
|
43
95
|
*/
|
|
44
96
|
export function ownerMemoryExtension(
|
|
45
97
|
memories: MemoryStore,
|
|
46
|
-
|
|
47
|
-
ownerId: string,
|
|
48
|
-
owner: OwnerIdentity,
|
|
49
|
-
/**
|
|
50
|
-
* The person the running turn is for, when it may be someone other than the owner. Their
|
|
51
|
-
* memory, not the owner's, is what the turn reads and changes; without it every turn is the owner's.
|
|
52
|
-
*/
|
|
53
|
-
speaker?: () => Speaker | undefined,
|
|
54
|
-
/** Whether the tools tell the model that each speaker has a memory of their own. */
|
|
55
|
-
describesSpeakers = speaker !== undefined,
|
|
98
|
+
memory: MemoryFor,
|
|
56
99
|
): ExtensionFactory {
|
|
57
|
-
const o =
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
return
|
|
100
|
+
const o = addresseeWords(memory.addressee);
|
|
101
|
+
// Whose memory the call reads and changes; its result records them.
|
|
102
|
+
const storeOf = () => {
|
|
103
|
+
const whose = memory.whose();
|
|
104
|
+
return (
|
|
105
|
+
whose && {
|
|
106
|
+
store: memories.forSpeaker(whose.principalId),
|
|
107
|
+
whose: whose.principalId,
|
|
108
|
+
}
|
|
109
|
+
);
|
|
62
110
|
};
|
|
63
|
-
const
|
|
64
|
-
const shared = describesSpeakers
|
|
111
|
+
const shared = memory.describesSpeakers
|
|
65
112
|
? " Whoever is speaking has a memory of their own, shared by every agent; this reads and changes theirs, not someone else's."
|
|
66
113
|
: "";
|
|
67
114
|
return (pi) => {
|
|
68
115
|
pi.on("before_agent_start", async (event) => {
|
|
116
|
+
const whose = memory.whose();
|
|
117
|
+
if (!whose) return;
|
|
69
118
|
const today = zonedToday();
|
|
70
119
|
const section = ownerMemorySection(
|
|
71
|
-
await
|
|
120
|
+
await memories.forSpeaker(whose.principalId).forPrompt(today),
|
|
72
121
|
today,
|
|
73
|
-
|
|
122
|
+
whose.principalId === memory.ownerId ? undefined : whose.name,
|
|
74
123
|
);
|
|
75
124
|
const appended = event.systemPromptOptions.appendSystemPrompt;
|
|
76
125
|
event.systemPromptOptions.appendSystemPrompt = appended
|
|
@@ -98,12 +147,19 @@ export function ownerMemoryExtension(
|
|
|
98
147
|
),
|
|
99
148
|
}),
|
|
100
149
|
execute: async (_toolCallId, params) => {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
const of = storeOf();
|
|
151
|
+
if (!of) return toolError(NO_ONE);
|
|
152
|
+
return ownAnswer(of.whose, async () => {
|
|
153
|
+
const saved = await of.store.add(
|
|
154
|
+
params.fact,
|
|
155
|
+
params.kind,
|
|
156
|
+
params.date,
|
|
157
|
+
);
|
|
158
|
+
return privateText(
|
|
159
|
+
`Remembered as ${saved.kind}: ${line(saved).slice(2)}`,
|
|
160
|
+
of.whose,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
107
163
|
},
|
|
108
164
|
});
|
|
109
165
|
|
|
@@ -115,14 +171,21 @@ export function ownerMemoryExtension(
|
|
|
115
171
|
query: Type.String({ description: "Keywords separated by spaces." }),
|
|
116
172
|
}),
|
|
117
173
|
execute: async (_toolCallId, params) => {
|
|
118
|
-
const
|
|
119
|
-
return
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
174
|
+
const of = storeOf();
|
|
175
|
+
if (!of) return toolError(NO_ONE);
|
|
176
|
+
return ownAnswer(of.whose, async () => {
|
|
177
|
+
const found = await of.store.search(params.query);
|
|
178
|
+
return privateText(
|
|
179
|
+
found.length === 0
|
|
180
|
+
? `Nothing remembered matches "${params.query}".`
|
|
181
|
+
: found
|
|
182
|
+
.map(
|
|
183
|
+
(memory) => `- [${memory.kind}] ${line(memory).slice(2)}`,
|
|
184
|
+
)
|
|
185
|
+
.join("\n"),
|
|
186
|
+
of.whose,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
126
189
|
},
|
|
127
190
|
});
|
|
128
191
|
|
|
@@ -136,15 +199,25 @@ export function ownerMemoryExtension(
|
|
|
136
199
|
}),
|
|
137
200
|
}),
|
|
138
201
|
execute: async (_toolCallId, params) => {
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
202
|
+
const of = storeOf();
|
|
203
|
+
if (!of) return toolError(NO_ONE);
|
|
204
|
+
return ownAnswer(of.whose, async () => {
|
|
205
|
+
const removed = await of.store.remove(params.text);
|
|
206
|
+
// The refusal repeats the text, which may be the person's memory, so it records whose too.
|
|
207
|
+
if (removed.length === 0) {
|
|
208
|
+
const details: PrivateMemory = { privateTo: of.whose };
|
|
209
|
+
return {
|
|
210
|
+
...toolError(
|
|
211
|
+
`No remembered fact contains "${params.text}". Nothing was forgotten.`,
|
|
212
|
+
),
|
|
213
|
+
details,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return privateText(
|
|
217
|
+
`Forgot:\n${removed.map((fact) => `- ${fact}`).join("\n")}`,
|
|
218
|
+
of.whose,
|
|
143
219
|
);
|
|
144
|
-
}
|
|
145
|
-
return toolText(
|
|
146
|
-
`Forgot:\n${removed.map((fact) => `- ${fact}`).join("\n")}`,
|
|
147
|
-
);
|
|
220
|
+
});
|
|
148
221
|
},
|
|
149
222
|
});
|
|
150
223
|
};
|
|
@@ -1,26 +1,66 @@
|
|
|
1
1
|
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import type {
|
|
4
|
-
import { type OwnerIdentity
|
|
5
|
-
import { toolText } from "../../shared/tool-result.ts";
|
|
3
|
+
import type { Notifier } from "../../domain/ports.ts";
|
|
4
|
+
import { addresseeWords, type OwnerIdentity } from "../../identity.ts";
|
|
5
|
+
import { toolError, toolText } from "../../shared/tool-result.ts";
|
|
6
6
|
|
|
7
|
+
/** What notify needs in one session. */
|
|
8
|
+
export interface SessionNotify {
|
|
9
|
+
/** Sends a person a notice through their own channel; false when none reaches them. */
|
|
10
|
+
notifier: Notifier;
|
|
11
|
+
/**
|
|
12
|
+
* Whom a call notifies, read when it runs: the conversation's person in a private one, the
|
|
13
|
+
* turn's speaker in a shared one; or why nobody is, such as in a turn nobody is named for.
|
|
14
|
+
*/
|
|
15
|
+
recipient: () => Promise<{ principalId: string } | { refused: string }>;
|
|
16
|
+
/** How a notice reaches them, completing "Send Ada …", such as "a direct message on Discord". */
|
|
17
|
+
channels: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Registers notify for one session: it sends a notice to the person the conversation serves,
|
|
22
|
+
* through the first direct channel that reaches them, and refuses when none does. Its 0.8 name,
|
|
23
|
+
* `notify_owner`, still names it in a selection and in `toolTiers` until 1.0 (`RENAMED_TOOLS`).
|
|
24
|
+
*/
|
|
7
25
|
export function notifyExtension(
|
|
8
|
-
|
|
9
|
-
|
|
26
|
+
notify: SessionNotify,
|
|
27
|
+
/** Whom the description names: the private conversation's person, or the speaker. */
|
|
28
|
+
addressee: OwnerIdentity,
|
|
29
|
+
/**
|
|
30
|
+
* Whom it names at each turn, when that may change while the session is open, such as when the
|
|
31
|
+
* host gains or loses an owner; the tool is described anew before a turn whose words differ.
|
|
32
|
+
*/
|
|
33
|
+
current?: () => Promise<OwnerIdentity>,
|
|
10
34
|
): ExtensionFactory {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
name: "
|
|
15
|
-
label: "Notify
|
|
16
|
-
description: `Send ${o.name}
|
|
35
|
+
const tool = (who: OwnerIdentity) => {
|
|
36
|
+
const o = addresseeWords(who);
|
|
37
|
+
return {
|
|
38
|
+
name: "notify",
|
|
39
|
+
label: "Notify",
|
|
40
|
+
description: `Send ${o.name} ${notify.channels}. Use only when ${o.he} asks to be notified or reminded by DM; your normal reply already reaches ${o.him}.`,
|
|
17
41
|
parameters: Type.Object({
|
|
18
42
|
text: Type.String({ description: "The message to send." }),
|
|
19
43
|
}),
|
|
20
|
-
execute: async (_toolCallId, params) => {
|
|
21
|
-
await
|
|
44
|
+
execute: async (_toolCallId: string, params: { text: string }) => {
|
|
45
|
+
const recipient = await notify.recipient();
|
|
46
|
+
if ("refused" in recipient) return toolError(recipient.refused);
|
|
47
|
+
if (!(await notify.notifier.notify(recipient.principalId, params.text)))
|
|
48
|
+
return toolError(
|
|
49
|
+
"the person this notice is for has no direct channel on this host to send it to",
|
|
50
|
+
);
|
|
22
51
|
return toolText("Sent.");
|
|
23
52
|
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
return (pi) => {
|
|
56
|
+
let described = tool(addressee);
|
|
57
|
+
pi.registerTool(described);
|
|
58
|
+
if (!current) return;
|
|
59
|
+
pi.on("before_agent_start", async () => {
|
|
60
|
+
const now = tool(await current());
|
|
61
|
+
if (now.description === described.description) return;
|
|
62
|
+
described = now;
|
|
63
|
+
pi.registerTool(now);
|
|
24
64
|
});
|
|
25
65
|
};
|
|
26
66
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { SYSTEM_PRINCIPAL } from "../../identity/principal-store.ts";
|
|
3
|
+
import type { OwnerIdentity } from "../../identity.ts";
|
|
4
|
+
import type { Logger } from "../../log.ts";
|
|
5
|
+
import type {
|
|
6
|
+
DirectChannelProvider,
|
|
7
|
+
DirectChannels,
|
|
8
|
+
} from "../../presence/direct-channels.ts";
|
|
9
|
+
import type { SessionContext } from "../../sessions.ts";
|
|
10
|
+
import { notifyExtension } from "./notify.ts";
|
|
11
|
+
|
|
12
|
+
export interface SessionNotifyDeps {
|
|
13
|
+
directChannels: DirectChannels;
|
|
14
|
+
/** The primary owner: the host's own turns, such as a report's, notify them, as 0.8 did. */
|
|
15
|
+
owner: OwnerIdentity & { id: string };
|
|
16
|
+
/**
|
|
17
|
+
* Whether a notice in a shared conversation can only be the primary owner's: the host has no
|
|
18
|
+
* other owner, and only owners may use notify. Its description then names them, as in 0.8.
|
|
19
|
+
*/
|
|
20
|
+
onlyOwnerNotified(): Promise<boolean>;
|
|
21
|
+
logger: Logger;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "a direct message on Discord", or each provider's label joined with "or". */
|
|
25
|
+
function labels(providers: readonly DirectChannelProvider[]): string {
|
|
26
|
+
return providers.map((provider) => provider.label).join(" or ");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The notify tool of each session: none on a host without direct channels, and none in a private
|
|
31
|
+
* conversation whose person no direct channel reaches. A notice goes to the conversation's
|
|
32
|
+
* person in a private one, and to the turn's speaker in a shared one.
|
|
33
|
+
*/
|
|
34
|
+
export function sessionNotify(
|
|
35
|
+
deps: SessionNotifyDeps,
|
|
36
|
+
): (session: SessionContext) => ExtensionFactory | null {
|
|
37
|
+
const { directChannels, owner, logger } = deps;
|
|
38
|
+
// Whom a notice is for: the person of a private conversation, the speaker of a shared one; the
|
|
39
|
+
// host's own turns notify the conversation's person, or else the primary owner. Anyone else
|
|
40
|
+
// speaking in someone's private conversation is refused, so their text never reaches its person.
|
|
41
|
+
const recipientOf = (
|
|
42
|
+
session: SessionContext,
|
|
43
|
+
own: string | undefined,
|
|
44
|
+
): { principalId: string } | { refused: string } => {
|
|
45
|
+
const speaker = session.speaker();
|
|
46
|
+
if (!speaker)
|
|
47
|
+
return {
|
|
48
|
+
refused:
|
|
49
|
+
"a notice is sent only in a turn someone is named for, whose notice it is",
|
|
50
|
+
};
|
|
51
|
+
if (speaker.principalId === SYSTEM_PRINCIPAL)
|
|
52
|
+
return { principalId: own ?? owner.id };
|
|
53
|
+
if (own !== undefined && speaker.principalId !== own)
|
|
54
|
+
return {
|
|
55
|
+
refused:
|
|
56
|
+
"this conversation is private to someone else, so a notice here is not yours to send",
|
|
57
|
+
};
|
|
58
|
+
return { principalId: speaker.principalId };
|
|
59
|
+
};
|
|
60
|
+
// A failed lookup names no one: the speaker's words are true whoever it is.
|
|
61
|
+
const onlyOwner = (session: SessionContext) =>
|
|
62
|
+
deps.onlyOwnerNotified().catch((error: unknown) => {
|
|
63
|
+
logger.warn(
|
|
64
|
+
{ channel: session.homeChannel, err: error },
|
|
65
|
+
"could not tell whether the primary owner is the only owner",
|
|
66
|
+
);
|
|
67
|
+
return false;
|
|
68
|
+
});
|
|
69
|
+
return (session) => {
|
|
70
|
+
const providers = directChannels.providers();
|
|
71
|
+
if (providers.length === 0) return null;
|
|
72
|
+
const { conversation } = session;
|
|
73
|
+
const own =
|
|
74
|
+
conversation.visibility === "private"
|
|
75
|
+
? conversation.principalId
|
|
76
|
+
: undefined;
|
|
77
|
+
return async (pi) => {
|
|
78
|
+
let reaching = providers;
|
|
79
|
+
if (own !== undefined) {
|
|
80
|
+
try {
|
|
81
|
+
const known = await directChannels.known(own);
|
|
82
|
+
if (!known) return;
|
|
83
|
+
reaching = [known];
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// No destination was established; do not advertise an unusable tool.
|
|
86
|
+
logger.warn(
|
|
87
|
+
{ channel: session.homeChannel, err: error },
|
|
88
|
+
"could not tell whether a direct channel reaches the conversation's person",
|
|
89
|
+
);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A shared conversation names the primary owner while only they can be notified, which
|
|
94
|
+
// an owner granted or revoked while the session is open changes before its next turn.
|
|
95
|
+
const describe = async () =>
|
|
96
|
+
own === undefined && (await onlyOwner(session))
|
|
97
|
+
? owner
|
|
98
|
+
: session.addressee;
|
|
99
|
+
await notifyExtension(
|
|
100
|
+
{
|
|
101
|
+
notifier: directChannels,
|
|
102
|
+
recipient: async () => recipientOf(session, own),
|
|
103
|
+
channels: labels(reaching),
|
|
104
|
+
},
|
|
105
|
+
await describe(),
|
|
106
|
+
own === undefined ? describe : undefined,
|
|
107
|
+
)(pi);
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -35,6 +35,11 @@ export interface PrecheckContext {
|
|
|
35
35
|
/** The due schedule, already moved to its next run (or deleted, when it runs once). */
|
|
36
36
|
schedule: Schedule;
|
|
37
37
|
firedAt: Date;
|
|
38
|
+
/**
|
|
39
|
+
* The tier this run is for: the schedule's, capped at what its creator holds now, as its turn
|
|
40
|
+
* would run. A precheck runs only for a creator who may run the turn.
|
|
41
|
+
*/
|
|
42
|
+
tier: Tier;
|
|
38
43
|
/** Aborted when the precheck runs out of time or the host stops; its answer is then ignored. */
|
|
39
44
|
signal: AbortSignal;
|
|
40
45
|
}
|
|
@@ -61,8 +66,9 @@ export interface PrecheckScope {
|
|
|
61
66
|
/** The schedule's background target, such as the owner's. */
|
|
62
67
|
target: string;
|
|
63
68
|
/**
|
|
64
|
-
* The tier of whoever set the script:
|
|
65
|
-
* describes it; absent when the asker's is unknown. A runner may grant
|
|
69
|
+
* The tier of whoever set the script: the run's (`PrecheckContext.tier`) when it runs, the
|
|
70
|
+
* asker's when schedule_list describes it; absent when the asker's is unknown. A runner may grant
|
|
71
|
+
* lower tiers less.
|
|
66
72
|
*/
|
|
67
73
|
tier?: Tier;
|
|
68
74
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HoldCheck } from "../../holds.ts";
|
|
2
2
|
import type { Logger } from "../../log.ts";
|
|
3
|
+
import type { Tier } from "../../speakers.ts";
|
|
3
4
|
import { timeZone, zonedStamp } from "../../time.ts";
|
|
4
5
|
import { type PrecheckTool, precheckScriptTools } from "./precheck-tools.ts";
|
|
5
6
|
import {
|
|
@@ -33,10 +34,12 @@ export class SchedulePrechecks {
|
|
|
33
34
|
this.#options = options;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
/** Runs the schedule's precheck for a run at `tier`, its creator's capped tier now. */
|
|
36
38
|
async run(
|
|
37
39
|
schedule: Schedule,
|
|
38
40
|
name: string,
|
|
39
41
|
firedAt: Date,
|
|
42
|
+
tier: Tier,
|
|
40
43
|
): Promise<PrecheckOutcome> {
|
|
41
44
|
const { logger } = this.#options;
|
|
42
45
|
let decision: PrecheckOutcome;
|
|
@@ -47,7 +50,7 @@ export class SchedulePrechecks {
|
|
|
47
50
|
? { kind: "failed", error: precheck }
|
|
48
51
|
: await runPrecheck(
|
|
49
52
|
precheck,
|
|
50
|
-
{ schedule, firedAt },
|
|
53
|
+
{ schedule, firedAt, tier },
|
|
51
54
|
{
|
|
52
55
|
signal: this.#options.signal,
|
|
53
56
|
settled: (run) => {
|
|
@@ -110,6 +110,34 @@ function toSchedule(row: Row): Schedule {
|
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/** Refuses a schedule the table could not run: a precheck and a script both, or a script without its tools. */
|
|
114
|
+
function checked(schedule: NewSchedule): void {
|
|
115
|
+
if (schedule.precheck && schedule.precheckScript)
|
|
116
|
+
throw new ScheduleError(
|
|
117
|
+
"a schedule has a precheck or a precheck script, not both",
|
|
118
|
+
);
|
|
119
|
+
if (schedule.precheckScript && !schedule.precheckTools)
|
|
120
|
+
throw new ScheduleError(
|
|
121
|
+
"a precheck script is saved with the tools it may call",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function insert(sql: SQL, schedule: NewSchedule): Promise<Schedule> {
|
|
126
|
+
checked(schedule);
|
|
127
|
+
const rows: Row[] = await sql`
|
|
128
|
+
INSERT INTO schedules (channel_key, mode, title, prompt, recurrence, next_run,
|
|
129
|
+
created_by_id, created_by_name, created_tier, precheck, precheck_script, precheck_tools)
|
|
130
|
+
VALUES (${schedule.channel}, ${schedule.target}, ${schedule.title}, ${schedule.prompt},
|
|
131
|
+
${JSON.stringify(schedule.recurrence)}, ${schedule.nextRun},
|
|
132
|
+
${schedule.createdById}, ${schedule.createdByName}, ${schedule.createdTier},
|
|
133
|
+
${schedule.precheck ?? null}, ${schedule.precheckScript ?? null},
|
|
134
|
+
${schedule.precheckScript ? JSON.stringify(schedule.precheckTools) : null})
|
|
135
|
+
RETURNING *`;
|
|
136
|
+
const [row] = rows;
|
|
137
|
+
if (!row) throw new Error("the schedule insert returned no row");
|
|
138
|
+
return toSchedule(row);
|
|
139
|
+
}
|
|
140
|
+
|
|
113
141
|
/** Scheduled turns, in the database. A one-time schedule's row is deleted once it fires. */
|
|
114
142
|
export class PgScheduleStore implements ScheduleStore {
|
|
115
143
|
readonly #sql: SQL;
|
|
@@ -176,26 +204,29 @@ export class PgScheduleStore implements ScheduleStore {
|
|
|
176
204
|
}
|
|
177
205
|
|
|
178
206
|
async create(schedule: NewSchedule): Promise<Schedule> {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
return insert(this.#sql, schedule);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Creates a schedule unless its creator already has `limit.max` of its target's schedules,
|
|
212
|
+
* counting those whose creator id is one of `limit.creators`; resolves the count reached instead.
|
|
213
|
+
* The count and the insert hold a lock on the creator, so creates at once by one person in many
|
|
214
|
+
* conversations stay within the limit.
|
|
215
|
+
*/
|
|
216
|
+
async createWithin(
|
|
217
|
+
schedule: NewSchedule,
|
|
218
|
+
limit: { creators: readonly string[]; max: number },
|
|
219
|
+
): Promise<{ created: Schedule } | { reached: number }> {
|
|
220
|
+
checked(schedule);
|
|
221
|
+
return this.#sql.begin(async (tx) => {
|
|
222
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`schedules/creator:${schedule.createdById}`}, 0))`;
|
|
223
|
+
const [row]: { count: string | number }[] = await tx`
|
|
224
|
+
SELECT count(*) AS count FROM schedules
|
|
225
|
+
WHERE mode = ${schedule.target} AND created_by_id IN ${tx([...limit.creators])}`;
|
|
226
|
+
const count = Number(row?.count ?? 0);
|
|
227
|
+
if (count >= limit.max) return { reached: count };
|
|
228
|
+
return { created: await insert(tx, schedule) };
|
|
229
|
+
});
|
|
199
230
|
}
|
|
200
231
|
|
|
201
232
|
async get(id: number): Promise<Schedule | undefined> {
|