pi-roundtable 0.7.15 → 0.7.16
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 +23 -0
- package/docs/plugins.md +73 -3
- package/package.json +2 -1
- 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/builtin/agent-server.ts +26 -0
- package/src/core/config/config.ts +14 -3
- package/src/core/contract/runtime.ts +5 -0
- package/src/core/define-roundtable.ts +1 -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/runtime/conversation-sessions.ts +7 -2
- package/src/core/runtime/extensions/confirmation-gate.ts +6 -7
- package/src/core/runtime/session-factory.ts +16 -0
- package/src/kit/index.ts +20 -0
- package/src/kit/jev.ts +357 -0
- package/src/kit/shell.ts +2 -0
|
@@ -1,7 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import type { HoldRule } from "../../holds.ts";
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import type { HoldContext, HoldRule } from "../../holds.ts";
|
|
4
3
|
import { assistantName } from "../../i18n/index.ts";
|
|
4
|
+
import { ownPush, type PushPolicy } from "./git-push.ts";
|
|
5
|
+
import {
|
|
6
|
+
follow,
|
|
7
|
+
insideRoots,
|
|
8
|
+
type LineState,
|
|
9
|
+
lineState,
|
|
10
|
+
resolvePath,
|
|
11
|
+
rmHeld,
|
|
12
|
+
} from "./shell-paths.ts";
|
|
13
|
+
|
|
14
|
+
export type { PushPolicy } from "./git-push.ts";
|
|
5
15
|
|
|
6
16
|
const MAX_SHOWN_COMMAND = 600;
|
|
7
17
|
|
|
@@ -18,7 +28,6 @@ const ALWAYS_HELD = new Set([
|
|
|
18
28
|
"sudo",
|
|
19
29
|
"su",
|
|
20
30
|
"doas",
|
|
21
|
-
"rm",
|
|
22
31
|
"dd",
|
|
23
32
|
"shutdown",
|
|
24
33
|
"reboot",
|
|
@@ -76,15 +85,17 @@ const WRAPPERS = new Set([
|
|
|
76
85
|
"xargs",
|
|
77
86
|
]);
|
|
78
87
|
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Whether a write to the word stays inside a scratch root. A word the line cannot resolve is read
|
|
90
|
+
* as written from the workspace, as before variables were followed.
|
|
91
|
+
*/
|
|
92
|
+
function writable(word: string, state: LineState): boolean {
|
|
93
|
+
if (word === "/dev/null") return true;
|
|
94
|
+
const path =
|
|
95
|
+
resolvePath(word, state) ??
|
|
96
|
+
resolvePath(word, { ...state, vars: new Map(), cwd: state.workspace }) ??
|
|
97
|
+
word;
|
|
98
|
+
return insideRoots(path, state);
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
/** Splits a command line into simple commands of words; quotes are removed, operators separate. */
|
|
@@ -123,8 +134,11 @@ export function simpleCommands(command: string): string[][] {
|
|
|
123
134
|
if (ch === "\n") endCommand();
|
|
124
135
|
else endWord();
|
|
125
136
|
} else if (";&|()`{}".includes(ch)) {
|
|
137
|
+
// A word cut by a command substitution keeps its mark, so its value counts as unknown.
|
|
138
|
+
if (ch === "`" && inWord) word += ch;
|
|
126
139
|
endCommand();
|
|
127
140
|
} else if (ch === "$" && command[i + 1] === "(") {
|
|
141
|
+
if (inWord) word += "$(";
|
|
128
142
|
endCommand();
|
|
129
143
|
i++;
|
|
130
144
|
} else if (ch === ">" || ch === "<") {
|
|
@@ -147,17 +161,22 @@ export function simpleCommands(command: string): string[][] {
|
|
|
147
161
|
return commands;
|
|
148
162
|
}
|
|
149
163
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
164
|
+
/**
|
|
165
|
+
* Drops leading variable assignments, wrappers, and their options, leaving the real program;
|
|
166
|
+
* `prefix` collects what was dropped.
|
|
167
|
+
*/
|
|
168
|
+
function program(words: string[], prefix: string[] = []): string[] {
|
|
152
169
|
let rest = words;
|
|
153
170
|
for (;;) {
|
|
154
171
|
const [first] = rest;
|
|
155
172
|
if (first === undefined) return rest;
|
|
156
173
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
|
|
174
|
+
prefix.push(first);
|
|
157
175
|
rest = rest.slice(1);
|
|
158
176
|
continue;
|
|
159
177
|
}
|
|
160
178
|
if (WRAPPERS.has(basename(first))) {
|
|
179
|
+
prefix.push(basename(first));
|
|
161
180
|
rest = rest.slice(1);
|
|
162
181
|
// Wrapper options and the timeout's duration.
|
|
163
182
|
while (
|
|
@@ -173,33 +192,37 @@ function program(words: string[]): string[] {
|
|
|
173
192
|
}
|
|
174
193
|
}
|
|
175
194
|
|
|
176
|
-
/** Why one simple command is held, or undefined. */
|
|
177
|
-
function heldReason(
|
|
195
|
+
/** Why one simple command is held, or undefined; reads the line's state as it is before it. */
|
|
196
|
+
function heldReason(
|
|
197
|
+
words: string[],
|
|
198
|
+
state: LineState,
|
|
199
|
+
policy: PushPolicy,
|
|
200
|
+
): string | undefined {
|
|
178
201
|
for (let i = 0; i < words.length; i++) {
|
|
179
202
|
const op = words[i];
|
|
180
203
|
if (op === ">" || op === ">>") {
|
|
181
204
|
const target = words[i + 1];
|
|
182
|
-
if (
|
|
183
|
-
target &&
|
|
184
|
-
target !== "/dev/null" &&
|
|
185
|
-
!insideWorkspace(target, workspace)
|
|
186
|
-
)
|
|
187
|
-
return `writes to ${target}`;
|
|
205
|
+
if (target && !writable(target, state)) return `writes to ${target}`;
|
|
188
206
|
}
|
|
189
207
|
}
|
|
208
|
+
const prefix: string[] = [];
|
|
190
209
|
const argv = program(
|
|
191
210
|
words.filter(
|
|
192
211
|
(w, i) =>
|
|
193
212
|
!["<", ">", ">>", "dup"].includes(w) &&
|
|
194
213
|
!["<", ">", ">>"].includes(words[i - 1] ?? ""),
|
|
195
214
|
),
|
|
215
|
+
prefix,
|
|
196
216
|
);
|
|
197
217
|
const [head, ...args] = argv;
|
|
198
218
|
if (!head) return undefined;
|
|
199
219
|
const name = basename(head);
|
|
200
220
|
if (ALWAYS_HELD.has(name) || /^mkfs(\..+)?$/.test(name)) return name;
|
|
221
|
+
if (name === "rm")
|
|
222
|
+
// xargs adds operands nobody can see here.
|
|
223
|
+
return prefix.includes("xargs") ? "rm" : rmHeld(args, state);
|
|
201
224
|
if ((name === "bash" || name === "sh" || name === "zsh") && args[0] === "-c")
|
|
202
|
-
return commandHeld(args[1] ?? "",
|
|
225
|
+
return commandHeld(args[1] ?? "", state, policy);
|
|
203
226
|
const verbs = args.filter((a) => !a.startsWith("-"));
|
|
204
227
|
switch (name) {
|
|
205
228
|
case "systemctl":
|
|
@@ -255,11 +278,14 @@ function heldReason(words: string[], workspace: string): string | undefined {
|
|
|
255
278
|
? "installs or removes packages"
|
|
256
279
|
: undefined;
|
|
257
280
|
case "git": {
|
|
258
|
-
const [verb, ...rest] = gitCommand(args);
|
|
259
|
-
if (verb === "push")
|
|
281
|
+
const [globals, [verb, ...rest]] = gitCommand(args);
|
|
282
|
+
if (verb === "push") {
|
|
283
|
+
const plain = !prefix.some((w) => w.startsWith("GIT_"));
|
|
284
|
+
if (plain && ownPush(globals, rest, state, policy)) return undefined;
|
|
260
285
|
return rest.some((a) => a === "-f" || a.startsWith("--force"))
|
|
261
286
|
? "force-pushes"
|
|
262
287
|
: `pushes to GitHub, which deploys ${assistantName()} when the branch is main`;
|
|
288
|
+
}
|
|
263
289
|
if (verb === "reset" && rest.includes("--hard"))
|
|
264
290
|
return "git reset --hard";
|
|
265
291
|
return undefined;
|
|
@@ -270,11 +296,9 @@ function heldReason(words: string[], workspace: string): string | undefined {
|
|
|
270
296
|
return args.includes("-r") ? "removes a crontab" : undefined;
|
|
271
297
|
case "tee": {
|
|
272
298
|
const target = args.find(
|
|
273
|
-
(a) => !a.startsWith("-") && !
|
|
299
|
+
(a) => !a.startsWith("-") && !writable(a, state),
|
|
274
300
|
);
|
|
275
|
-
return target
|
|
276
|
-
? `writes to ${target}`
|
|
277
|
-
: undefined;
|
|
301
|
+
return target ? `writes to ${target}` : undefined;
|
|
278
302
|
}
|
|
279
303
|
default:
|
|
280
304
|
return undefined;
|
|
@@ -290,12 +314,12 @@ const GIT_VALUE_OPTIONS = new Set([
|
|
|
290
314
|
"--namespace",
|
|
291
315
|
]);
|
|
292
316
|
|
|
293
|
-
/**
|
|
294
|
-
function gitCommand(args: string[]): string[] {
|
|
317
|
+
/** Git's global options, such as `-C <dir>`, and the subcommand with its arguments. */
|
|
318
|
+
function gitCommand(args: string[]): [string[], string[]] {
|
|
295
319
|
let i = 0;
|
|
296
320
|
while (i < args.length && (args[i] as string).startsWith("-"))
|
|
297
321
|
i += GIT_VALUE_OPTIONS.has(args[i] as string) ? 2 : 1;
|
|
298
|
-
return args.slice(i);
|
|
322
|
+
return [args.slice(0, i), args.slice(i)];
|
|
299
323
|
}
|
|
300
324
|
|
|
301
325
|
const GH_BODY_FLAGS = ["-f", "-F", "--field", "--raw-field", "--input"];
|
|
@@ -348,44 +372,83 @@ function ghHeld(args: string[]): string | undefined {
|
|
|
348
372
|
}
|
|
349
373
|
}
|
|
350
374
|
|
|
351
|
-
|
|
375
|
+
/** Why a command line is held; a `bash -c` inside one starts from the outer line's state. */
|
|
376
|
+
function commandHeld(
|
|
377
|
+
command: string,
|
|
378
|
+
outer: LineState,
|
|
379
|
+
policy: PushPolicy,
|
|
380
|
+
): string | undefined {
|
|
381
|
+
const state: LineState = {
|
|
382
|
+
...lineState(command, outer.workspace, undefined),
|
|
383
|
+
roots: outer.roots,
|
|
384
|
+
vars: new Map(outer.vars),
|
|
385
|
+
cwd: outer.cwd,
|
|
386
|
+
};
|
|
352
387
|
for (const words of simpleCommands(command)) {
|
|
353
|
-
const reason = heldReason(words,
|
|
388
|
+
const reason = heldReason(words, state, policy);
|
|
354
389
|
if (reason) return reason;
|
|
390
|
+
follow(program(words), state);
|
|
391
|
+
// `export` and `cd` are kept by program(); bare assignments are dropped by it.
|
|
392
|
+
if (words.every((w) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w)))
|
|
393
|
+
follow(words, state);
|
|
355
394
|
}
|
|
356
395
|
return undefined;
|
|
357
396
|
}
|
|
358
397
|
|
|
359
398
|
/**
|
|
360
|
-
* Pi's shell and file tools: a bash command that is destructive or reaches outside the
|
|
361
|
-
* workspace, and a write or edit outside
|
|
399
|
+
* Pi's shell and file tools: a bash command that is destructive or reaches outside the scratch
|
|
400
|
+
* roots (the shared workspace and the scratch dir), and a write or edit outside them, wait for the
|
|
401
|
+
* owner. Reading always runs.
|
|
362
402
|
*/
|
|
363
403
|
export function shellActionNeedingConfirmation(
|
|
364
404
|
toolName: string,
|
|
365
405
|
input: Record<string, unknown>,
|
|
366
|
-
workspace: string,
|
|
406
|
+
context: HoldContext & { workspace: string },
|
|
407
|
+
policy: PushPolicy = {},
|
|
367
408
|
): string | undefined {
|
|
409
|
+
const state = lineState(
|
|
410
|
+
typeof input.command === "string" ? input.command : "",
|
|
411
|
+
context.workspace,
|
|
412
|
+
context.scratchDir,
|
|
413
|
+
);
|
|
368
414
|
if (toolName === "bash") {
|
|
369
415
|
const command = typeof input.command === "string" ? input.command : "";
|
|
370
|
-
const reason = commandHeld(command,
|
|
416
|
+
const reason = commandHeld(command, state, policy);
|
|
371
417
|
return reason
|
|
372
418
|
? `run the shell command \`${command.slice(0, MAX_SHOWN_COMMAND)}\` on the host (${reason})`
|
|
373
419
|
: undefined;
|
|
374
420
|
}
|
|
375
421
|
if (toolName === "write" || toolName === "edit") {
|
|
376
422
|
const path = typeof input.path === "string" ? input.path : "";
|
|
377
|
-
return
|
|
423
|
+
return writable(path.replace(/^@/, ""), state)
|
|
378
424
|
? undefined
|
|
379
425
|
: `${toolName} the file ${path} on the host`;
|
|
380
426
|
}
|
|
381
427
|
return undefined;
|
|
382
428
|
}
|
|
383
429
|
|
|
384
|
-
/**
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
430
|
+
/**
|
|
431
|
+
* The shell rule with a push policy: a plain push to a repository of `ownPushOwners`, other than
|
|
432
|
+
* `heldPushRepos`, runs without a hold. Shell calls of a session without a workspace have no shell.
|
|
433
|
+
*/
|
|
434
|
+
export function shellHoldRuleFor(policy: PushPolicy = {}): Readonly<HoldRule> {
|
|
435
|
+
const frozen: PushPolicy = {
|
|
436
|
+
ownPushOwners: [...(policy.ownPushOwners ?? [])],
|
|
437
|
+
heldPushRepos: [...(policy.heldPushRepos ?? [])],
|
|
438
|
+
};
|
|
439
|
+
return Object.freeze<HoldRule>({
|
|
440
|
+
name: "shell",
|
|
441
|
+
describe: (tool, input, context) =>
|
|
442
|
+
context.workspace === undefined
|
|
443
|
+
? undefined
|
|
444
|
+
: shellActionNeedingConfirmation(
|
|
445
|
+
tool,
|
|
446
|
+
input,
|
|
447
|
+
{ ...context, workspace: context.workspace },
|
|
448
|
+
frozen,
|
|
449
|
+
),
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The strict shell rule: every push is held. */
|
|
454
|
+
export const shellHoldRule: Readonly<HoldRule> = shellHoldRuleFor();
|
|
@@ -63,12 +63,17 @@ export class ConversationSessions {
|
|
|
63
63
|
async gate(key: ChannelKey, agent: boolean): Promise<ConfirmationGate> {
|
|
64
64
|
let gate = this.#gates.get(key);
|
|
65
65
|
if (!gate) {
|
|
66
|
-
const
|
|
66
|
+
const agents = agent ? this.#options.agents : undefined;
|
|
67
67
|
gate = new ConfirmationGate(
|
|
68
68
|
this.#factory.link().holds,
|
|
69
69
|
this.#options.owner,
|
|
70
70
|
await this.#options.confirmations.load(key),
|
|
71
|
-
|
|
71
|
+
agents
|
|
72
|
+
? {
|
|
73
|
+
workspace: agents.workDir,
|
|
74
|
+
...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
|
|
75
|
+
}
|
|
76
|
+
: {},
|
|
72
77
|
this.#tiers,
|
|
73
78
|
);
|
|
74
79
|
this.#gates.set(key, gate);
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
PendingConfirmation,
|
|
5
5
|
} from "../../domain/conversation.ts";
|
|
6
6
|
import type { OwnerPrompts } from "../../domain/owner-prompts.ts";
|
|
7
|
-
import { type HoldCheck, higherTier } from "../../holds.ts";
|
|
7
|
+
import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
|
|
8
8
|
import { messages } from "../../i18n/index.ts";
|
|
9
9
|
import { type OwnerIdentity, ownerWords } from "../../identity.ts";
|
|
10
10
|
import type { ToolTiers } from "../../tool-tiers.ts";
|
|
@@ -60,18 +60,18 @@ export class ConfirmationGate {
|
|
|
60
60
|
readonly #owner: OwnerIdentity;
|
|
61
61
|
/** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
|
|
62
62
|
#addressee: OwnerIdentity;
|
|
63
|
-
readonly #
|
|
63
|
+
readonly #context: HoldContext;
|
|
64
64
|
readonly #tiers: ToolTiers | undefined;
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
67
|
* `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
|
|
68
|
-
* and its writes outside the workspace are held too.
|
|
68
|
+
* and its writes outside the workspace and the context's scratch dir are held too.
|
|
69
69
|
*/
|
|
70
70
|
constructor(
|
|
71
71
|
holds: HoldCheck,
|
|
72
72
|
owner: OwnerIdentity,
|
|
73
73
|
pending?: PendingConfirmation,
|
|
74
|
-
|
|
74
|
+
context: HoldContext = {},
|
|
75
75
|
tiers?: ToolTiers,
|
|
76
76
|
) {
|
|
77
77
|
this.#tiers = tiers;
|
|
@@ -79,7 +79,7 @@ export class ConfirmationGate {
|
|
|
79
79
|
this.#owner = owner;
|
|
80
80
|
this.#addressee = owner;
|
|
81
81
|
this.#pending = pending;
|
|
82
|
-
this.#
|
|
82
|
+
this.#context = context;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/** Held actions awaiting the owner's answer; expired ones count as none. */
|
|
@@ -156,8 +156,7 @@ export class ConfirmationGate {
|
|
|
156
156
|
|
|
157
157
|
/** The call when it needs the owner's confirmation and no approval releases it. */
|
|
158
158
|
#needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
|
|
159
|
-
const context =
|
|
160
|
-
this.#workspace === undefined ? {} : { workspace: this.#workspace };
|
|
159
|
+
const context = this.#context;
|
|
161
160
|
const action = this.#holds(tool, input, context);
|
|
162
161
|
if (!action) return undefined;
|
|
163
162
|
const minTier = this.#holds.approvalTier?.(tool, input, context);
|
|
@@ -3,9 +3,11 @@ import { join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
type AgentSession,
|
|
5
5
|
createAgentSession,
|
|
6
|
+
createBashToolDefinition,
|
|
6
7
|
DefaultResourceLoader,
|
|
7
8
|
type ModelRuntime,
|
|
8
9
|
type SessionManager,
|
|
10
|
+
type ToolDefinition,
|
|
9
11
|
} from "@earendil-works/pi-coding-agent";
|
|
10
12
|
import { channelSegment } from "../attachments/attachment-dir.ts";
|
|
11
13
|
import type { ChannelKey } from "../domain/conversation.ts";
|
|
@@ -279,6 +281,10 @@ export class SessionFactory {
|
|
|
279
281
|
const { session } = await createAgentSession({
|
|
280
282
|
cwd,
|
|
281
283
|
agentDir,
|
|
284
|
+
// Pi's bash under its own name, run with TMPDIR at the agents' scratch dir.
|
|
285
|
+
...(agents?.scratchDir
|
|
286
|
+
? { customTools: [scratchBash(cwd, agents.scratchDir)] }
|
|
287
|
+
: {}),
|
|
282
288
|
thinkingLevel: thinking,
|
|
283
289
|
modelRuntime: this.#modelRuntime,
|
|
284
290
|
resourceLoader,
|
|
@@ -305,3 +311,13 @@ export class SessionFactory {
|
|
|
305
311
|
return Object.assign(state, { session });
|
|
306
312
|
}
|
|
307
313
|
}
|
|
314
|
+
|
|
315
|
+
/** Pi's bash tool with TMPDIR set to `scratchDir`, so `mktemp` and tools write there. */
|
|
316
|
+
export function scratchBash(cwd: string, scratchDir: string): ToolDefinition {
|
|
317
|
+
return createBashToolDefinition(cwd, {
|
|
318
|
+
spawnHook: (context) => ({
|
|
319
|
+
...context,
|
|
320
|
+
env: { ...context.env, TMPDIR: scratchDir },
|
|
321
|
+
}),
|
|
322
|
+
}) as unknown as ToolDefinition;
|
|
323
|
+
}
|
package/src/kit/index.ts
CHANGED
|
@@ -44,6 +44,24 @@ export {
|
|
|
44
44
|
thinkingLabel,
|
|
45
45
|
} from "./domain.ts";
|
|
46
46
|
export { holdChain } from "./holds.ts";
|
|
47
|
+
export type {
|
|
48
|
+
JevCompactInput,
|
|
49
|
+
JevCompactOptions,
|
|
50
|
+
JevCompactOutcome,
|
|
51
|
+
JevCompactor,
|
|
52
|
+
JevCompactRequest,
|
|
53
|
+
JevExtensionOptions,
|
|
54
|
+
JevSkipReason,
|
|
55
|
+
} from "./jev.ts";
|
|
56
|
+
export {
|
|
57
|
+
isRuleLoad,
|
|
58
|
+
JEV_COMPACTION_ENGINE,
|
|
59
|
+
JEV_GOAL,
|
|
60
|
+
JEV_PREVIOUS_SUMMARY_LIMIT_TOKENS,
|
|
61
|
+
jevCompact,
|
|
62
|
+
jevCompactionExtension,
|
|
63
|
+
jevCompactor,
|
|
64
|
+
} from "./jev.ts";
|
|
47
65
|
export type {
|
|
48
66
|
EffortBrief,
|
|
49
67
|
EffortJudgeOptions,
|
|
@@ -79,8 +97,10 @@ export {
|
|
|
79
97
|
} from "./presentation.ts";
|
|
80
98
|
export { packageDir, serveUnix } from "./process.ts";
|
|
81
99
|
export {
|
|
100
|
+
type PushPolicy,
|
|
82
101
|
SHELL_TOOLS,
|
|
83
102
|
shellHoldRule,
|
|
103
|
+
shellHoldRuleFor,
|
|
84
104
|
} from "./shell.ts";
|
|
85
105
|
export {
|
|
86
106
|
checkRepoName,
|