pi-roundtable 0.7.15 → 0.7.17
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 +36 -0
- package/docs/plugins.md +94 -4
- package/package.json +2 -1
- package/src/core/agents/agent-ports.ts +9 -0
- package/src/core/agents/agent-prompt.ts +3 -1
- package/src/core/agents/agent-team.ts +2 -0
- package/src/core/agents/team-options.ts +2 -0
- package/src/core/agents/team-turn-types.ts +1 -1
- package/src/core/agents/team-turns.ts +24 -0
- package/src/core/builtin/agent-server.ts +37 -0
- package/src/core/config/config.ts +30 -3
- package/src/core/contract/runtime.ts +5 -0
- package/src/core/contract/surface.ts +8 -0
- package/src/core/define-roundtable.ts +3 -0
- package/src/core/discord/agent-discord.ts +33 -0
- package/src/core/discord/discord-surface.ts +20 -0
- package/src/core/domain/interim.ts +16 -0
- package/src/core/domain/ports.ts +6 -0
- package/src/core/holds.ts +2 -0
- package/src/core/modules/host-shell/git-push.ts +112 -0
- package/src/core/modules/host-shell/shell-paths.ts +160 -0
- package/src/core/modules/host-shell/shell-policy.ts +111 -48
- package/src/core/routing/conversation-turns.ts +3 -0
- package/src/core/routing/surface-port.ts +1 -0
- package/src/core/runtime/conversation-sessions.ts +7 -2
- package/src/core/runtime/extensions/ask-user.ts +1 -1
- package/src/core/runtime/extensions/confirmation-gate.ts +6 -7
- package/src/core/runtime/interim-text.ts +207 -0
- package/src/core/runtime/pi-agent-runtime.ts +13 -2
- package/src/core/runtime/prompt-slot.ts +13 -2
- package/src/core/runtime/runtime-types.ts +8 -0
- package/src/core/runtime/session-factory.ts +16 -0
- package/src/index.ts +5 -0
- package/src/kit/index.ts +20 -0
- package/src/kit/jev.ts +357 -0
- package/src/kit/shell.ts +2 -0
package/src/kit/jev.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Compaction through Jev (pi-jev-compaction), for host sessions and sandbox sessions alike.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ExtensionFactory,
|
|
5
|
+
SessionBeforeCompactEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import {
|
|
8
|
+
buildVerbatimSummary,
|
|
9
|
+
type CompactResult,
|
|
10
|
+
collectToolCalls,
|
|
11
|
+
compact,
|
|
12
|
+
estimateSummaryTokens,
|
|
13
|
+
extractKeptMessages,
|
|
14
|
+
type FallbackKind,
|
|
15
|
+
fileOpsFromPreparation,
|
|
16
|
+
type JevAsker,
|
|
17
|
+
JevClient,
|
|
18
|
+
PACKAGE_NAME,
|
|
19
|
+
PACKAGE_VERSION,
|
|
20
|
+
type PackageConfig,
|
|
21
|
+
type PiAgentMessage,
|
|
22
|
+
type PiCompactionResult,
|
|
23
|
+
reductionRatio,
|
|
24
|
+
resolvePackageConfig,
|
|
25
|
+
summarizedJevMessages,
|
|
26
|
+
toJevTranscript,
|
|
27
|
+
} from "pi-jev-compaction";
|
|
28
|
+
import type { ChannelKey } from "../core/domain/conversation.ts";
|
|
29
|
+
import type { Logger } from "../core/log.ts";
|
|
30
|
+
|
|
31
|
+
/** The engine Jev's compactions record in their details, as pi-jev-compaction's own extension does. */
|
|
32
|
+
export const JEV_COMPACTION_ENGINE = PACKAGE_NAME;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Past this, the previous summary is condensed by Pi's own summary instead: Jev keeps text
|
|
36
|
+
* verbatim, so every Jev summary carries the one before it whole and the chain only grows.
|
|
37
|
+
*/
|
|
38
|
+
export const JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS = 60_000;
|
|
39
|
+
|
|
40
|
+
/** What Jev keeps when it judges which tool calls still matter. */
|
|
41
|
+
export const JEV_GOAL =
|
|
42
|
+
"Keep tool results that set rules still in force for the ongoing work: skills, notebook system prompts and AGENTS.md, note formats and style guides, read with invoke-skill, get-system-prompt or read. Drop stale lookups, listings, and finished edits.";
|
|
43
|
+
|
|
44
|
+
const RULE_TOOL = /(^|-)(invoke-skill|get-system-prompt)$/;
|
|
45
|
+
const READ_TOOL = /(^|-)read$/;
|
|
46
|
+
const RULE_FILE = /(\/\.agents\/skills\/|(^|\/)(SKILL|AGENTS)\.md$)/;
|
|
47
|
+
const ARGS_CHARS = 200;
|
|
48
|
+
|
|
49
|
+
/** Whether a tool call loads rules: a skill, a notebook system prompt, or a read of SKILL.md, AGENTS.md or `.agents/skills/`. */
|
|
50
|
+
export function isRuleLoad(tool: string, args: unknown): boolean {
|
|
51
|
+
if (RULE_TOOL.test(tool)) return true;
|
|
52
|
+
if (!READ_TOOL.test(tool) || typeof args !== "object" || args === null)
|
|
53
|
+
return false;
|
|
54
|
+
const path = (args as { path?: unknown }).path;
|
|
55
|
+
return typeof path === "string" && RULE_FILE.test(path);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface JevCompactOptions {
|
|
59
|
+
/** What Jev keeps; default `JEV_GOAL`. */
|
|
60
|
+
goal?: string;
|
|
61
|
+
/** A previous summary above this many tokens skips Jev; default `JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS`. */
|
|
62
|
+
previousSummaryLimitTokens?: number;
|
|
63
|
+
/** Which tool calls the summary lists to load again; default `isRuleLoad`. */
|
|
64
|
+
ruleLoad?: (tool: string, args: unknown) => boolean;
|
|
65
|
+
/** pi-jev-compaction's settings (apiKey, model, thresholds), over its config file and environment. */
|
|
66
|
+
config?: PackageConfig;
|
|
67
|
+
/** Answers Jev's questions instead of Jev's service; for tests. */
|
|
68
|
+
asker?: JevAsker;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pi's compaction preparation, as Jev reads it. */
|
|
72
|
+
export interface JevCompactInput {
|
|
73
|
+
messagesToSummarize: readonly PiAgentMessage[];
|
|
74
|
+
turnPrefixMessages?: readonly PiAgentMessage[];
|
|
75
|
+
/** The messages that stay in the context, which Jev sees but the summary leaves out. */
|
|
76
|
+
keptMessages?: readonly PiAgentMessage[];
|
|
77
|
+
previousSummary?: string;
|
|
78
|
+
firstKeptEntryId: string;
|
|
79
|
+
tokensBefore: number;
|
|
80
|
+
customInstructions?: string;
|
|
81
|
+
reason?: string;
|
|
82
|
+
readFiles?: string[];
|
|
83
|
+
modifiedFiles?: string[];
|
|
84
|
+
signal?: AbortSignal;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Why Jev left a compaction to Pi's summary. */
|
|
88
|
+
export type JevSkipReason = FallbackKind | "previous_summary_too_large";
|
|
89
|
+
|
|
90
|
+
export type JevCompactOutcome =
|
|
91
|
+
| { compaction: PiCompactionResult }
|
|
92
|
+
| { skipped: JevSkipReason; detail?: string };
|
|
93
|
+
|
|
94
|
+
function classifyError(message: string): FallbackKind {
|
|
95
|
+
if (/aborted/i.test(message)) return "aborted";
|
|
96
|
+
if (/too large for Jev|no room for questions/i.test(message))
|
|
97
|
+
return "cannot_fit";
|
|
98
|
+
return "jev_error";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Compacts through Jev. The outcome is `skipped`, for Pi's summary to run, when the previous
|
|
103
|
+
* summary is too large or Jev falls back. A Jev summary carries the previous summary once, in the
|
|
104
|
+
* transcript, and ends with the rule loads among the summarized messages so the agent loads them again.
|
|
105
|
+
*/
|
|
106
|
+
export async function jevCompact(
|
|
107
|
+
input: JevCompactInput,
|
|
108
|
+
options: JevCompactOptions = {},
|
|
109
|
+
): Promise<JevCompactOutcome> {
|
|
110
|
+
const previous = input.previousSummary?.trim() ?? "";
|
|
111
|
+
const limit =
|
|
112
|
+
options.previousSummaryLimitTokens ?? JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS;
|
|
113
|
+
if (estimateSummaryTokens(previous) > limit)
|
|
114
|
+
return { skipped: "previous_summary_too_large" };
|
|
115
|
+
const config = resolvePackageConfig({
|
|
116
|
+
...options.config,
|
|
117
|
+
goal: options.goal ?? options.config?.goal ?? JEV_GOAL,
|
|
118
|
+
});
|
|
119
|
+
if (input.signal?.aborted) return { skipped: "aborted" };
|
|
120
|
+
if (!options.asker && !config.apiKey)
|
|
121
|
+
return { skipped: "no_key", detail: "no API key is configured" };
|
|
122
|
+
const transcript = toJevTranscript({
|
|
123
|
+
previousSummary: previous || undefined,
|
|
124
|
+
messagesToSummarize: input.messagesToSummarize,
|
|
125
|
+
turnPrefixMessages: input.turnPrefixMessages,
|
|
126
|
+
keptMessages: input.keptMessages ?? [],
|
|
127
|
+
});
|
|
128
|
+
if (transcript.compactableCount === 0)
|
|
129
|
+
return { skipped: "nothing_to_compact" };
|
|
130
|
+
const candidates = collectToolCalls(
|
|
131
|
+
transcript.messages,
|
|
132
|
+
config.preserveRecentMessages,
|
|
133
|
+
).filter((call) => !call.pinned);
|
|
134
|
+
if (candidates.length === 0) return { skipped: "no_candidates" };
|
|
135
|
+
const asker =
|
|
136
|
+
options.asker ??
|
|
137
|
+
new JevClient({
|
|
138
|
+
apiKey: config.apiKey,
|
|
139
|
+
model: config.model,
|
|
140
|
+
baseUrl: config.baseUrl,
|
|
141
|
+
signal: input.signal,
|
|
142
|
+
});
|
|
143
|
+
let result: CompactResult;
|
|
144
|
+
try {
|
|
145
|
+
result = await compact(transcript.messages, asker, {
|
|
146
|
+
goal: config.goal,
|
|
147
|
+
keepThreshold: config.keepThreshold,
|
|
148
|
+
preserveRecentMessages: config.preserveRecentMessages,
|
|
149
|
+
maxStateTokens: config.maxStateTokens,
|
|
150
|
+
maxRequestTokens: config.maxRequestTokens,
|
|
151
|
+
truncateHeadChars: config.truncateHeadChars,
|
|
152
|
+
});
|
|
153
|
+
} catch (error) {
|
|
154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
155
|
+
return { skipped: classifyError(message), detail: message };
|
|
156
|
+
}
|
|
157
|
+
if (input.signal?.aborted) return { skipped: "aborted" };
|
|
158
|
+
const reduction = reductionRatio(result);
|
|
159
|
+
if (reduction < config.minReductionRatio)
|
|
160
|
+
return {
|
|
161
|
+
skipped: "reduction_too_small",
|
|
162
|
+
detail: `${Math.round(reduction * 100)}% below ${Math.round(config.minReductionRatio * 100)}%`,
|
|
163
|
+
};
|
|
164
|
+
// The transcript already opens with the previous summary, so the summary's own copy is left out.
|
|
165
|
+
let summary = buildVerbatimSummary(
|
|
166
|
+
summarizedJevMessages(result.messages, transcript),
|
|
167
|
+
result,
|
|
168
|
+
{
|
|
169
|
+
reason: input.reason,
|
|
170
|
+
customInstructions: input.customInstructions,
|
|
171
|
+
readFiles: input.readFiles,
|
|
172
|
+
modifiedFiles: input.modifiedFiles,
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
const reloads = ruleLoads(
|
|
176
|
+
[...input.messagesToSummarize, ...(input.turnPrefixMessages ?? [])],
|
|
177
|
+
options.ruleLoad ?? isRuleLoad,
|
|
178
|
+
);
|
|
179
|
+
if (reloads.length > 0)
|
|
180
|
+
summary = `${summary.trimEnd()}\n\n${reloadSection(reloads)}\n`;
|
|
181
|
+
return {
|
|
182
|
+
compaction: {
|
|
183
|
+
summary,
|
|
184
|
+
firstKeptEntryId: input.firstKeptEntryId,
|
|
185
|
+
tokensBefore: input.tokensBefore,
|
|
186
|
+
estimatedTokensAfter: estimateSummaryTokens(summary),
|
|
187
|
+
details: {
|
|
188
|
+
engine: PACKAGE_NAME,
|
|
189
|
+
version: PACKAGE_VERSION,
|
|
190
|
+
stats: result.stats,
|
|
191
|
+
decisions: result.decisions,
|
|
192
|
+
reductionRatio: reduction,
|
|
193
|
+
readFiles: input.readFiles,
|
|
194
|
+
modifiedFiles: input.modifiedFiles,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The rule loads among the messages, as `tool args` lines, first occurrence first. */
|
|
201
|
+
function ruleLoads(
|
|
202
|
+
messages: readonly PiAgentMessage[],
|
|
203
|
+
matches: (tool: string, args: unknown) => boolean,
|
|
204
|
+
): string[] {
|
|
205
|
+
const lines = new Set<string>();
|
|
206
|
+
for (const message of messages) {
|
|
207
|
+
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
208
|
+
continue;
|
|
209
|
+
for (const block of message.content) {
|
|
210
|
+
if (block.type !== "toolCall" || typeof block.name !== "string") continue;
|
|
211
|
+
const args = block.arguments ?? block.input ?? {};
|
|
212
|
+
if (!matches(block.name, args)) continue;
|
|
213
|
+
const json = JSON.stringify(args) ?? "";
|
|
214
|
+
lines.add(
|
|
215
|
+
`- \`${block.name}\` ${json.length > ARGS_CHARS ? `${json.slice(0, ARGS_CHARS)}…` : json}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return [...lines];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function reloadSection(lines: readonly string[]): string {
|
|
223
|
+
return [
|
|
224
|
+
"## Rules loaded before this compaction",
|
|
225
|
+
"These skill and rule loads may have been dropped from the history above. Load them again before you follow them or write by them:",
|
|
226
|
+
...lines,
|
|
227
|
+
].join("\n");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface JevExtensionOptions extends JevCompactOptions {
|
|
231
|
+
/** Receives each skip, with its reason and the tokens before; a missing key is logged once. */
|
|
232
|
+
logger: Logger;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The `compaction` session tool's extension: Jev, or Pi's summary whenever it skips. Place it
|
|
237
|
+
* through the core's tiers: `session.compaction.wrap(jevCompactionExtension({ logger }))`.
|
|
238
|
+
*/
|
|
239
|
+
export function jevCompactionExtension(
|
|
240
|
+
options: JevExtensionOptions,
|
|
241
|
+
): ExtensionFactory {
|
|
242
|
+
const logSkip = skipLogger();
|
|
243
|
+
return (pi) => {
|
|
244
|
+
pi.on(
|
|
245
|
+
"session_before_compact",
|
|
246
|
+
async ({
|
|
247
|
+
preparation,
|
|
248
|
+
branchEntries,
|
|
249
|
+
customInstructions,
|
|
250
|
+
reason,
|
|
251
|
+
signal,
|
|
252
|
+
}: SessionBeforeCompactEvent) => {
|
|
253
|
+
const files = fileOpsFromPreparation(preparation.fileOps);
|
|
254
|
+
const outcome = await jevCompact(
|
|
255
|
+
{
|
|
256
|
+
messagesToSummarize:
|
|
257
|
+
preparation.messagesToSummarize as PiAgentMessage[],
|
|
258
|
+
turnPrefixMessages:
|
|
259
|
+
preparation.turnPrefixMessages as PiAgentMessage[],
|
|
260
|
+
keptMessages: extractKeptMessages(
|
|
261
|
+
branchEntries as Parameters<typeof extractKeptMessages>[0],
|
|
262
|
+
preparation.firstKeptEntryId,
|
|
263
|
+
),
|
|
264
|
+
previousSummary: preparation.previousSummary,
|
|
265
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
266
|
+
tokensBefore: preparation.tokensBefore,
|
|
267
|
+
customInstructions,
|
|
268
|
+
reason,
|
|
269
|
+
readFiles: files.readFiles,
|
|
270
|
+
modifiedFiles: files.modifiedFiles,
|
|
271
|
+
signal,
|
|
272
|
+
},
|
|
273
|
+
options,
|
|
274
|
+
);
|
|
275
|
+
if ("compaction" in outcome) return { compaction: outcome.compaction };
|
|
276
|
+
logSkip(options.logger, outcome, preparation.tokensBefore);
|
|
277
|
+
return undefined;
|
|
278
|
+
},
|
|
279
|
+
);
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A sandbox session's compaction preparation, as pi-roundtable-sandbox sends it to the host. */
|
|
284
|
+
export interface JevCompactRequest {
|
|
285
|
+
reason: string;
|
|
286
|
+
tokensBefore: number;
|
|
287
|
+
firstKeptEntryId: string;
|
|
288
|
+
messagesToSummarize: readonly object[];
|
|
289
|
+
turnPrefixMessages: readonly object[];
|
|
290
|
+
keptMessages: readonly object[];
|
|
291
|
+
previousSummary?: string;
|
|
292
|
+
customInstructions?: string;
|
|
293
|
+
readFiles: string[];
|
|
294
|
+
modifiedFiles: string[];
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The shape pi-roundtable-sandbox's `compaction` option takes. */
|
|
298
|
+
export interface JevCompactor {
|
|
299
|
+
engine: string;
|
|
300
|
+
compact(
|
|
301
|
+
request: JevCompactRequest,
|
|
302
|
+
context: { channel: ChannelKey; signal: AbortSignal },
|
|
303
|
+
): Promise<PiCompactionResult | undefined>;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A sandbox compactor: `new PiSandboxRuntime({ ..., compaction: jevCompactor({ logger }) })`. */
|
|
307
|
+
export function jevCompactor(options: JevExtensionOptions): JevCompactor {
|
|
308
|
+
const logSkip = skipLogger();
|
|
309
|
+
return {
|
|
310
|
+
engine: JEV_COMPACTION_ENGINE,
|
|
311
|
+
async compact(request, { channel, signal }) {
|
|
312
|
+
const outcome = await jevCompact(
|
|
313
|
+
{
|
|
314
|
+
messagesToSummarize: request.messagesToSummarize as PiAgentMessage[],
|
|
315
|
+
turnPrefixMessages: request.turnPrefixMessages as PiAgentMessage[],
|
|
316
|
+
keptMessages: request.keptMessages as PiAgentMessage[],
|
|
317
|
+
previousSummary: request.previousSummary,
|
|
318
|
+
firstKeptEntryId: request.firstKeptEntryId,
|
|
319
|
+
tokensBefore: request.tokensBefore,
|
|
320
|
+
customInstructions: request.customInstructions,
|
|
321
|
+
reason: request.reason,
|
|
322
|
+
readFiles: request.readFiles,
|
|
323
|
+
modifiedFiles: request.modifiedFiles,
|
|
324
|
+
signal,
|
|
325
|
+
},
|
|
326
|
+
options,
|
|
327
|
+
);
|
|
328
|
+
if ("compaction" in outcome) return outcome.compaction;
|
|
329
|
+
logSkip(options.logger.child({ channel }), outcome, request.tokensBefore);
|
|
330
|
+
return undefined;
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Logs each skip; a missing key only the first time, as Pi's summary is then the setup, not a fallback. */
|
|
336
|
+
function skipLogger() {
|
|
337
|
+
let toldNoKey = false;
|
|
338
|
+
return (
|
|
339
|
+
logger: Logger,
|
|
340
|
+
outcome: { skipped: JevSkipReason; detail?: string },
|
|
341
|
+
tokensBefore: number,
|
|
342
|
+
): void => {
|
|
343
|
+
if (outcome.skipped === "no_key") {
|
|
344
|
+
if (toldNoKey) return;
|
|
345
|
+
toldNoKey = true;
|
|
346
|
+
logger.info(
|
|
347
|
+
{ reason: outcome.skipped },
|
|
348
|
+
"Jev is not configured, so compaction uses Pi's summary",
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
logger.info(
|
|
353
|
+
{ reason: outcome.skipped, detail: outcome.detail, tokensBefore },
|
|
354
|
+
"Jev leaves the compaction to Pi's summary",
|
|
355
|
+
);
|
|
356
|
+
};
|
|
357
|
+
}
|
package/src/kit/shell.ts
CHANGED