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
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type {
|
|
3
|
+
ContextWithSystemEvent,
|
|
4
|
+
ExtensionFactory,
|
|
5
|
+
SessionBeforeCompactEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { MEMORY_TOOLS } from "../../modules/memory/owner-memory.ts";
|
|
8
|
+
|
|
9
|
+
type Messages = ContextWithSystemEvent["messages"];
|
|
10
|
+
|
|
11
|
+
/** What the model reads in place of a result that holds someone else's memory. */
|
|
12
|
+
export const HIDDEN_MEMORY = "(another person's private memory, hidden)";
|
|
13
|
+
/** What it reads in place of a memory result recorded before results said whose they were. */
|
|
14
|
+
export const HIDDEN_UNRECORDED_MEMORY =
|
|
15
|
+
"(a private memory result that does not say whose it is, hidden)";
|
|
16
|
+
|
|
17
|
+
const MEMORY = new Set<string>(MEMORY_TOOLS);
|
|
18
|
+
|
|
19
|
+
/** Whether a session with these tools registered loaded someone's memory: its memory tools, and its prompt's section with them. */
|
|
20
|
+
export function loadsMemory(registered: ReadonlySet<string>): boolean {
|
|
21
|
+
return MEMORY_TOOLS.some((name) => registered.has(name));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The principal a result's details say it is private to, if they say. */
|
|
25
|
+
function privateTo(details: unknown): string | undefined {
|
|
26
|
+
if (typeof details !== "object" || details === null) return undefined;
|
|
27
|
+
const whose = (details as Record<string, unknown>).privateTo;
|
|
28
|
+
return typeof whose === "string" ? whose : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Whose memory a request may carry, and whether its conversation is shared by several people. */
|
|
32
|
+
export interface MemoryView {
|
|
33
|
+
shared: boolean;
|
|
34
|
+
/** The principal whose memory the running turn reads; undefined for no one's. */
|
|
35
|
+
reader: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface GuardView extends MemoryView {
|
|
39
|
+
/** Bridge guard only: untagged built-in results belong to this legacy/recorded reader. */
|
|
40
|
+
legacyOwner?: string;
|
|
41
|
+
/** The recorded reader owns even unanswered built-in memory calls of this turn. */
|
|
42
|
+
turnReader?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Internal bridge check, including attribution of malformed or unanswered calls to their turn. */
|
|
46
|
+
export function guardHidesPrivateExchange(
|
|
47
|
+
messages: Messages,
|
|
48
|
+
view: GuardView,
|
|
49
|
+
): boolean {
|
|
50
|
+
return hiddenExchanges(messages, view).size > 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
type Assistant = Extract<Messages[number], { role: "assistant" }>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The apis whose signature on an answer's text names the message it was, its id and phase, rather
|
|
57
|
+
* than reasoning: OpenAI's Responses, which every other api's signed text is not. Gemini signs a
|
|
58
|
+
* text part with the thought signature of the reasoning behind it, and its converter sends it back.
|
|
59
|
+
*/
|
|
60
|
+
const MESSAGE_SIGNED = new Set([
|
|
61
|
+
"openai-responses",
|
|
62
|
+
"azure-openai-responses",
|
|
63
|
+
"openai-codex-responses",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the answer's replayed items were paired with reasoning it replays: OpenAI's Responses
|
|
68
|
+
* pairs the ids of a response's message and calls with its reasoning item, and refuses a replay
|
|
69
|
+
* that carries the ids without that item.
|
|
70
|
+
*/
|
|
71
|
+
function pairsReplay(message: Assistant): boolean {
|
|
72
|
+
return (
|
|
73
|
+
MESSAGE_SIGNED.has(message.api) &&
|
|
74
|
+
message.content.some((part) => part.type === "thinking")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A call's id without the item id OpenAI's Responses paired with its reasoning: `call|fc_…` reads `call`. */
|
|
79
|
+
const unpairedCallId = (id: string): string => id.split("|")[0] ?? id;
|
|
80
|
+
|
|
81
|
+
/** A message's signature without its item id, keeping its phase; undefined when it had none. */
|
|
82
|
+
function unpairedSignature(signature: string): string | undefined {
|
|
83
|
+
try {
|
|
84
|
+
const parsed: unknown = JSON.parse(signature);
|
|
85
|
+
const phase =
|
|
86
|
+
typeof parsed === "object" && parsed !== null && "phase" in parsed
|
|
87
|
+
? parsed.phase
|
|
88
|
+
: undefined;
|
|
89
|
+
return typeof phase === "string"
|
|
90
|
+
? JSON.stringify({ v: 1, id: "", phase })
|
|
91
|
+
: undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* An earlier answer without its reasoning, or undefined when it gave none: its thinking, encrypted
|
|
99
|
+
* or not, the thought signatures of its tool calls, and those its provider signed its text with,
|
|
100
|
+
* which may carry a person's memory as the turn read it. Where OpenAI's Responses paired the
|
|
101
|
+
* answer's message and call ids with that reasoning, they go with it; each call keeps the id its
|
|
102
|
+
* result answers, and each message its phase.
|
|
103
|
+
*/
|
|
104
|
+
function withoutReasoning(message: Assistant): Assistant | undefined {
|
|
105
|
+
const signsReasoning = !MESSAGE_SIGNED.has(message.api);
|
|
106
|
+
const unpair = pairsReplay(message);
|
|
107
|
+
let changed = false;
|
|
108
|
+
const content = message.content.flatMap((part): Assistant["content"] => {
|
|
109
|
+
if (part.type === "thinking") {
|
|
110
|
+
changed = true;
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
if (part.type === "toolCall") {
|
|
114
|
+
const { thoughtSignature, ...call } = part;
|
|
115
|
+
const id = unpair ? unpairedCallId(part.id) : part.id;
|
|
116
|
+
if (thoughtSignature === undefined && id === part.id) return [part];
|
|
117
|
+
changed = true;
|
|
118
|
+
return [{ ...call, id }];
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
part.type === "text" &&
|
|
122
|
+
part.textSignature !== undefined &&
|
|
123
|
+
(signsReasoning || unpair)
|
|
124
|
+
) {
|
|
125
|
+
changed = true;
|
|
126
|
+
const { textSignature, ...text } = part;
|
|
127
|
+
const kept = signsReasoning
|
|
128
|
+
? undefined
|
|
129
|
+
: unpairedSignature(textSignature);
|
|
130
|
+
return [kept === undefined ? text : { ...text, textSignature: kept }];
|
|
131
|
+
}
|
|
132
|
+
return [part];
|
|
133
|
+
});
|
|
134
|
+
return changed ? { ...message, content } : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The ids of the earlier answers' calls whose reasoning goes, and their pairing with it. */
|
|
138
|
+
function unpairedCalls(messages: Messages, before: number): Set<string> {
|
|
139
|
+
const ids = new Set<string>();
|
|
140
|
+
for (const message of messages.slice(0, Math.max(before, 0)))
|
|
141
|
+
if (message.role === "assistant" && pairsReplay(message))
|
|
142
|
+
for (const part of message.content)
|
|
143
|
+
if (part.type === "toolCall") ids.add(part.id);
|
|
144
|
+
return ids;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether raw history contains a private exchange this view would hide (not reasoning/prompt changes). */
|
|
148
|
+
export function hidesPrivateExchange(
|
|
149
|
+
messages: ContextWithSystemEvent["messages"],
|
|
150
|
+
view: MemoryView,
|
|
151
|
+
): boolean {
|
|
152
|
+
return (
|
|
153
|
+
hiddenExchanges(messages, { shared: view.shared, reader: view.reader })
|
|
154
|
+
.size > 0
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The request's messages as the view allows, or undefined when they need no change. A private
|
|
160
|
+
* exchange reads as a placeholder for other readers, its call's arguments as well as its result.
|
|
161
|
+
* In shared history unowned built-in memory calls are hidden too, earlier reasoning is removed,
|
|
162
|
+
* and persisted prompt states collapse to the current prompt alone.
|
|
163
|
+
*/
|
|
164
|
+
export function memoryProjection(
|
|
165
|
+
messages: ContextWithSystemEvent["messages"],
|
|
166
|
+
view: MemoryView,
|
|
167
|
+
): ContextWithSystemEvent["messages"] | undefined {
|
|
168
|
+
// The running turn starts at the last message someone wrote; its own reasoning stays its own.
|
|
169
|
+
const running = view.shared
|
|
170
|
+
? messages.findLastIndex((message) => message.role === "user")
|
|
171
|
+
: -1;
|
|
172
|
+
const hidden = hiddenExchanges(messages, {
|
|
173
|
+
shared: view.shared,
|
|
174
|
+
reader: view.reader,
|
|
175
|
+
});
|
|
176
|
+
const unpaired = unpairedCalls(messages, running);
|
|
177
|
+
let changed = false;
|
|
178
|
+
const shown = messages.map((message, index) => {
|
|
179
|
+
const projected = projectedMessage(message, {
|
|
180
|
+
hidden,
|
|
181
|
+
unpaired,
|
|
182
|
+
earlier: index < running,
|
|
183
|
+
});
|
|
184
|
+
if (!projected) return message;
|
|
185
|
+
changed = true;
|
|
186
|
+
return projected;
|
|
187
|
+
});
|
|
188
|
+
const prompts = shown.filter((message) => message.role === "system").length;
|
|
189
|
+
if (!view.shared || prompts <= 1) return changed ? shown : undefined;
|
|
190
|
+
const head = getCurrentSystemMessage(shown);
|
|
191
|
+
if (!head) return changed ? shown : undefined;
|
|
192
|
+
return [head, ...shown.filter((message) => message.role !== "system")];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type ToolResult = Extract<Messages[number], { role: "toolResult" }>;
|
|
196
|
+
|
|
197
|
+
/** What a result reads as in the view when it holds memory the view may not show; undefined when it shows. */
|
|
198
|
+
function hiddenResult(result: ToolResult, view: GuardView): string | undefined {
|
|
199
|
+
const whose = privateTo(result.details);
|
|
200
|
+
if (whose !== undefined)
|
|
201
|
+
return whose === view.reader ? undefined : HIDDEN_MEMORY;
|
|
202
|
+
// A failed exchange too: its call's arguments are what the model wrote from someone's memory.
|
|
203
|
+
if (!view.shared || !MEMORY.has(result.toolName)) return undefined;
|
|
204
|
+
if (view.legacyOwner !== undefined && view.reader === view.legacyOwner)
|
|
205
|
+
return undefined;
|
|
206
|
+
return HIDDEN_UNRECORDED_MEMORY;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The exchanges the view hides, by their call's id, and what each reads as: those whose result
|
|
211
|
+
* holds memory it may not show, and, in a shared conversation, a memory call no result answers,
|
|
212
|
+
* such as one a stopped turn left, which says nothing of whose memory it wrote.
|
|
213
|
+
*/
|
|
214
|
+
function hiddenExchanges(
|
|
215
|
+
messages: Messages,
|
|
216
|
+
view: GuardView,
|
|
217
|
+
): Map<string, string> {
|
|
218
|
+
const hidden = new Map<string, string>();
|
|
219
|
+
const answered = new Set<string>();
|
|
220
|
+
for (const message of messages) {
|
|
221
|
+
if (message.role !== "toolResult") continue;
|
|
222
|
+
answered.add(message.toolCallId);
|
|
223
|
+
const text = hiddenResult(message, view);
|
|
224
|
+
if (text) hidden.set(message.toolCallId, text);
|
|
225
|
+
}
|
|
226
|
+
if (!view.shared) return hidden;
|
|
227
|
+
for (const message of messages) {
|
|
228
|
+
if (message.role !== "assistant") continue;
|
|
229
|
+
for (const part of message.content)
|
|
230
|
+
if (
|
|
231
|
+
part.type === "toolCall" &&
|
|
232
|
+
MEMORY.has(part.name) &&
|
|
233
|
+
!answered.has(part.id) &&
|
|
234
|
+
!(view.turnReader !== undefined && view.turnReader === view.reader)
|
|
235
|
+
)
|
|
236
|
+
hidden.set(part.id, HIDDEN_UNRECORDED_MEMORY);
|
|
237
|
+
}
|
|
238
|
+
return hidden;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* An answer whose hidden calls read with placeholder arguments, or undefined when it has none.
|
|
243
|
+
* Each stays a call by its id and name, as every provider expects of a call its result answers.
|
|
244
|
+
*/
|
|
245
|
+
function withHiddenCalls(
|
|
246
|
+
message: Assistant,
|
|
247
|
+
hidden: ReadonlyMap<string, string>,
|
|
248
|
+
): Assistant | undefined {
|
|
249
|
+
let changed = false;
|
|
250
|
+
const content = message.content.map((part) => {
|
|
251
|
+
if (part.type !== "toolCall") return part;
|
|
252
|
+
const text = hidden.get(part.id);
|
|
253
|
+
if (text === undefined) return part;
|
|
254
|
+
changed = true;
|
|
255
|
+
const { thoughtSignature: _, ...call } = part;
|
|
256
|
+
return { ...call, arguments: { hidden: text } };
|
|
257
|
+
});
|
|
258
|
+
return changed ? { ...message, content } : undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** What a message's projection hides, and whether it is of a turn before the running one. */
|
|
262
|
+
interface Projection {
|
|
263
|
+
/** The hidden exchanges' placeholders, by their call's original id. */
|
|
264
|
+
hidden: ReadonlyMap<string, string>;
|
|
265
|
+
/** The calls whose replay pairing goes with their answer's reasoning, by their original id. */
|
|
266
|
+
unpaired: ReadonlySet<string>;
|
|
267
|
+
earlier: boolean;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** One message as the projection allows, or undefined when it needs no change. */
|
|
271
|
+
function projectedMessage(
|
|
272
|
+
message: Messages[number],
|
|
273
|
+
{ hidden, unpaired, earlier }: Projection,
|
|
274
|
+
): Messages[number] | undefined {
|
|
275
|
+
if (message.role === "assistant") {
|
|
276
|
+
const shown = withHiddenCalls(message, hidden);
|
|
277
|
+
const reasoned = earlier ? withoutReasoning(shown ?? message) : undefined;
|
|
278
|
+
return reasoned ?? shown;
|
|
279
|
+
}
|
|
280
|
+
if (message.role !== "toolResult") return undefined;
|
|
281
|
+
const text = hidden.get(message.toolCallId);
|
|
282
|
+
const id = unpaired.has(message.toolCallId)
|
|
283
|
+
? unpairedCallId(message.toolCallId)
|
|
284
|
+
: message.toolCallId;
|
|
285
|
+
if (text === undefined && id === message.toolCallId) return undefined;
|
|
286
|
+
const owner = privateTo(message);
|
|
287
|
+
return {
|
|
288
|
+
...message,
|
|
289
|
+
toolCallId: id,
|
|
290
|
+
...(text === undefined
|
|
291
|
+
? {}
|
|
292
|
+
: {
|
|
293
|
+
content: [{ type: "text" as const, text }],
|
|
294
|
+
details: owner ? { privateTo: owner } : undefined,
|
|
295
|
+
nestedCalls: undefined,
|
|
296
|
+
}),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* What a shared conversation's compaction may summarize of its history: as no one's turn reads it,
|
|
302
|
+
* without the prompt states, whose memory sections are someone's, and without reasoning, which may
|
|
303
|
+
* restate the memory its turn read. A summary outlives the turns it covers and every later speaker
|
|
304
|
+
* reads it, so it is written from no one's memory.
|
|
305
|
+
*/
|
|
306
|
+
export function summaryProjection(
|
|
307
|
+
messages: ContextWithSystemEvent["messages"],
|
|
308
|
+
/** Full exchange history when calls/results straddle a compaction boundary. */
|
|
309
|
+
history: ContextWithSystemEvent["messages"] = messages,
|
|
310
|
+
): ContextWithSystemEvent["messages"] {
|
|
311
|
+
const projection: Projection = {
|
|
312
|
+
hidden: hiddenExchanges(history, { shared: true, reader: undefined }),
|
|
313
|
+
unpaired: unpairedCalls(history, history.length),
|
|
314
|
+
earlier: true,
|
|
315
|
+
};
|
|
316
|
+
return messages.flatMap((message) =>
|
|
317
|
+
message.role === "system"
|
|
318
|
+
? []
|
|
319
|
+
: [projectedMessage(message, projection) ?? message],
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Has a shared conversation's compaction summarize its history as `summaryProjection` gives it.
|
|
325
|
+
* Pi hands every `session_before_compact` handler, and its own summary after them, the same
|
|
326
|
+
* preparation, so each that reads it later reads the projection.
|
|
327
|
+
*/
|
|
328
|
+
export function privateCompaction(
|
|
329
|
+
preparation: SessionBeforeCompactEvent["preparation"],
|
|
330
|
+
/** Messages also handed to a custom compactor, such as the kept tail. */
|
|
331
|
+
additionalMessages: ContextWithSystemEvent["messages"] = [],
|
|
332
|
+
): void {
|
|
333
|
+
const history = [
|
|
334
|
+
...preparation.messagesToSummarize,
|
|
335
|
+
...preparation.turnPrefixMessages,
|
|
336
|
+
...additionalMessages,
|
|
337
|
+
];
|
|
338
|
+
preparation.messagesToSummarize = summaryProjection(
|
|
339
|
+
preparation.messagesToSummarize,
|
|
340
|
+
history,
|
|
341
|
+
);
|
|
342
|
+
preparation.turnPrefixMessages = summaryProjection(
|
|
343
|
+
preparation.turnPrefixMessages,
|
|
344
|
+
history,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* The tool calls a session runs at once, and those of them that drew on the reader's memory while
|
|
350
|
+
* they ran, such as one whose task's worker read it: their results hold that memory too.
|
|
351
|
+
*/
|
|
352
|
+
export class MemoryDraws {
|
|
353
|
+
readonly #running = new Set<string>();
|
|
354
|
+
/** The call each running call was made by, when another tool made it. */
|
|
355
|
+
readonly #parents = new Map<string, string>();
|
|
356
|
+
readonly #drawn = new Set<string>();
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Something run within these calls, outermost first, read the reader's memory: the calls of
|
|
360
|
+
* this session among them draw on it, and so do the calls that made them. Called from outside
|
|
361
|
+
* every call of this session, it cannot say whose, so every call running now draws on it.
|
|
362
|
+
*/
|
|
363
|
+
drawn(within: readonly string[]): void {
|
|
364
|
+
const own = within.filter((id) => this.#running.has(id));
|
|
365
|
+
if (own.length === 0) {
|
|
366
|
+
for (const id of this.#running) this.#drawn.add(id);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
for (const id of own)
|
|
370
|
+
for (
|
|
371
|
+
let at: string | undefined = id;
|
|
372
|
+
at !== undefined && this.#running.has(at);
|
|
373
|
+
at = this.#parents.get(at)
|
|
374
|
+
)
|
|
375
|
+
this.#drawn.add(at);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
start(toolCallId: string, parentToolCallId?: string): void {
|
|
379
|
+
this.#running.add(toolCallId);
|
|
380
|
+
if (parentToolCallId !== undefined)
|
|
381
|
+
this.#parents.set(toolCallId, parentToolCallId);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Whether the call drew on the reader's memory; it is forgotten, as it ended. */
|
|
385
|
+
end(toolCallId: string): boolean {
|
|
386
|
+
this.#running.delete(toolCallId);
|
|
387
|
+
this.#parents.delete(toolCallId);
|
|
388
|
+
return this.#drawn.delete(toolCallId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Projects each model request of a session so it carries no one's memory but the running turn's
|
|
394
|
+
* reader's: a person's memory is theirs per request, never the history's to share. A tool result
|
|
395
|
+
* that drew on the reader's memory, the call whose task's worker read it and the calls that call
|
|
396
|
+
* runs within, records it as theirs, as a memory tool's does; a call merely running beside it does
|
|
397
|
+
* not. A shared conversation's compaction summarizes no one's memory.
|
|
398
|
+
*
|
|
399
|
+
* What it guarantees in a shared conversation: a memory exchange, a memory tool's call and result
|
|
400
|
+
* or a call whose task's worker loaded the reader's memory and its result, paired by the call's id,
|
|
401
|
+
* reaches neither another speaker's request, the host's own turns included, nor a summary: the
|
|
402
|
+
* call's arguments read as `{ hidden: <placeholder> }`, its result as the placeholder. The answers
|
|
403
|
+
* of earlier turns go without the reasoning their provider replays, each provider's as it signs
|
|
404
|
+
* it. Anything else the model writes, into another tool's arguments or its reply, is public: what
|
|
405
|
+
* it carries of a person's memory there reaches whoever reads the conversation.
|
|
406
|
+
*/
|
|
407
|
+
export function privateMemoryExtension(
|
|
408
|
+
shared: boolean,
|
|
409
|
+
reader: () => string | undefined,
|
|
410
|
+
draws: MemoryDraws,
|
|
411
|
+
): ExtensionFactory {
|
|
412
|
+
return (pi) => {
|
|
413
|
+
pi.on("tool_execution_start", (event) => {
|
|
414
|
+
draws.start(event.toolCallId, event.parentToolCallId);
|
|
415
|
+
});
|
|
416
|
+
pi.on("tool_result", (event) => {
|
|
417
|
+
if (!draws.end(event.toolCallId)) return undefined;
|
|
418
|
+
const whose = reader();
|
|
419
|
+
if (whose === undefined) return undefined;
|
|
420
|
+
const details = "details" in event ? event.details : undefined;
|
|
421
|
+
return {
|
|
422
|
+
details: {
|
|
423
|
+
...(typeof details === "object" && details !== null ? details : {}),
|
|
424
|
+
privateTo: whose,
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
// The compaction extension placed through the core's wrapper got the projection already; Pi's
|
|
429
|
+
// own summary, which runs when no extension answers, reads it from here.
|
|
430
|
+
if (shared)
|
|
431
|
+
pi.on("session_before_compact", (event) => {
|
|
432
|
+
privateCompaction(
|
|
433
|
+
event.preparation,
|
|
434
|
+
event.branchEntries.flatMap((entry) =>
|
|
435
|
+
entry.type === "message" ? [entry.message] : [],
|
|
436
|
+
),
|
|
437
|
+
);
|
|
438
|
+
});
|
|
439
|
+
pi.on("context_with_system", (event) => {
|
|
440
|
+
const messages = memoryProjection(event.messages, {
|
|
441
|
+
shared,
|
|
442
|
+
reader: reader(),
|
|
443
|
+
});
|
|
444
|
+
return messages ? { messages } : undefined;
|
|
445
|
+
});
|
|
446
|
+
};
|
|
447
|
+
}
|
|
@@ -11,6 +11,7 @@ interface Row {
|
|
|
11
11
|
held_at: Date;
|
|
12
12
|
calls: string;
|
|
13
13
|
speaker_id: string | null;
|
|
14
|
+
principal_id: string | null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/** Each owner channel's held actions, in the database, so a restart keeps them. */
|
|
@@ -55,12 +56,27 @@ export class PendingConfirmationStore {
|
|
|
55
56
|
},
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The principal of that speaker, who approves the actions on any of their identities, and the
|
|
61
|
+
* hold it belongs to, by its `held_at`. 0.8 replaces a row's held actions and speaker and
|
|
62
|
+
* leaves `principal_id` as it was, so after a rollback and an upgrade a principal counts only
|
|
63
|
+
* while the row still holds the actions it was written with.
|
|
64
|
+
*/
|
|
65
|
+
static readonly principalMigration: Migration = {
|
|
66
|
+
name: "held-actions-principal",
|
|
67
|
+
up: async (sql) => {
|
|
68
|
+
await sql`ALTER TABLE held_actions ADD COLUMN IF NOT EXISTS principal_id text`;
|
|
69
|
+
await sql`ALTER TABLE held_actions ADD COLUMN IF NOT EXISTS principal_held_at timestamptz`;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
58
73
|
/** The store's migrations in order. */
|
|
59
74
|
static migrations(): Migration[] {
|
|
60
75
|
return [
|
|
61
76
|
PendingConfirmationStore.migration,
|
|
62
77
|
PendingConfirmationStore.speakerMigration,
|
|
63
78
|
PendingConfirmationStore.speakerHoldMigration,
|
|
79
|
+
PendingConfirmationStore.principalMigration,
|
|
64
80
|
];
|
|
65
81
|
}
|
|
66
82
|
|
|
@@ -70,10 +86,12 @@ export class PendingConfirmationStore {
|
|
|
70
86
|
}
|
|
71
87
|
|
|
72
88
|
async load(channel: ChannelKey): Promise<PendingConfirmation | undefined> {
|
|
73
|
-
// A speaker written with other held actions than the row's, or without its hold, is none.
|
|
89
|
+
// A speaker or principal written with other held actions than the row's, or without its hold, is none.
|
|
74
90
|
const rows: Row[] = await this.#sql`
|
|
75
91
|
SELECT selection_id, held_at, calls,
|
|
76
|
-
CASE WHEN speaker_held_at = held_at THEN speaker_id END AS speaker_id
|
|
92
|
+
CASE WHEN speaker_held_at = held_at THEN speaker_id END AS speaker_id,
|
|
93
|
+
CASE WHEN speaker_held_at = held_at AND principal_held_at = held_at
|
|
94
|
+
THEN principal_id END AS principal_id
|
|
77
95
|
FROM held_actions
|
|
78
96
|
WHERE channel_key = ${channel}`;
|
|
79
97
|
const row = rows[0];
|
|
@@ -84,6 +102,9 @@ export class PendingConfirmationStore {
|
|
|
84
102
|
// pi-lens-ignore: unchecked-throwing-call — this store wrote the JSON; a corrupt row should fail loudly
|
|
85
103
|
calls: JSON.parse(row.calls) as HeldCall[],
|
|
86
104
|
...(row.speaker_id === null ? {} : { speakerId: row.speaker_id }),
|
|
105
|
+
...(row.speaker_id === null || row.principal_id === null
|
|
106
|
+
? {}
|
|
107
|
+
: { principalId: row.principal_id }),
|
|
87
108
|
}
|
|
88
109
|
: undefined;
|
|
89
110
|
}
|
|
@@ -98,14 +119,18 @@ export class PendingConfirmationStore {
|
|
|
98
119
|
return;
|
|
99
120
|
}
|
|
100
121
|
const speaker = pending.speakerId ?? null;
|
|
122
|
+
const principal = speaker === null ? null : (pending.principalId ?? null);
|
|
101
123
|
await this.#sql`
|
|
102
124
|
INSERT INTO held_actions
|
|
103
|
-
(channel_key, selection_id, held_at, calls, speaker_id, speaker_held_at
|
|
125
|
+
(channel_key, selection_id, held_at, calls, speaker_id, speaker_held_at,
|
|
126
|
+
principal_id, principal_held_at)
|
|
104
127
|
VALUES (${channel}, ${pending.selectionId}, ${pending.heldAt},
|
|
105
128
|
${JSON.stringify(pending.calls)}, ${speaker},
|
|
106
|
-
${speaker === null ? null : pending.heldAt}
|
|
129
|
+
${speaker === null ? null : pending.heldAt}, ${principal},
|
|
130
|
+
${principal === null ? null : pending.heldAt})
|
|
107
131
|
ON CONFLICT (channel_key) DO UPDATE SET selection_id = EXCLUDED.selection_id,
|
|
108
132
|
held_at = EXCLUDED.held_at, calls = EXCLUDED.calls,
|
|
109
|
-
speaker_id = EXCLUDED.speaker_id, speaker_held_at = EXCLUDED.speaker_held_at
|
|
133
|
+
speaker_id = EXCLUDED.speaker_id, speaker_held_at = EXCLUDED.speaker_held_at,
|
|
134
|
+
principal_id = EXCLUDED.principal_id, principal_held_at = EXCLUDED.principal_held_at`;
|
|
110
135
|
}
|
|
111
136
|
}
|