pi-roundtable 0.9.3 → 0.9.6

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +4 -0
  3. package/README.zh-TW.md +4 -0
  4. package/docs/plugins.md +32 -6
  5. package/package.json +1 -1
  6. package/src/core/agents/agent-prompt.ts +1 -1
  7. package/src/core/builtin/discord-admin.ts +6 -2
  8. package/src/core/builtin/discord.ts +10 -0
  9. package/src/core/config/adapters.ts +47 -0
  10. package/src/core/config/config.ts +9 -34
  11. package/src/core/define-roundtable.ts +2 -0
  12. package/src/core/define.ts +8 -0
  13. package/src/core/discord/channel-files.ts +107 -0
  14. package/src/core/discord/channel-operations.ts +100 -33
  15. package/src/core/discord/discord-surface.ts +21 -4
  16. package/src/core/discord/dispatch-thread-host.ts +11 -0
  17. package/src/core/discord/dispatch-threads.ts +14 -0
  18. package/src/core/discord/inbound-message.ts +34 -0
  19. package/src/core/discord/owner-card-rows.ts +98 -0
  20. package/src/core/discord/owner-cards-fixture.ts +3 -0
  21. package/src/core/discord/owner-cards.ts +137 -105
  22. package/src/core/host.ts +9 -1
  23. package/src/core/i18n/cards.ts +6 -8
  24. package/src/core/i18n/conversation.ts +15 -0
  25. package/src/core/interactions/prompts.ts +36 -7
  26. package/src/core/modules/discord-admin/discord-admin.ts +16 -2
  27. package/src/core/plugin.ts +5 -0
  28. package/src/core/runtime/compaction-tiers.ts +61 -8
  29. package/src/core/runtime/conversation-sessions.ts +2 -0
  30. package/src/core/runtime/extensions/ask-user.ts +21 -4
  31. package/src/core/runtime/extensions/confirmation-gate.ts +73 -11
  32. package/src/core/runtime/prompt-slot.ts +6 -4
  33. package/src/core/runtime/runtime-types.ts +5 -0
  34. package/src/core/runtime/session-factory.ts +22 -2
  35. package/src/core/sessions.ts +6 -0
  36. package/src/core/shared/attach-file-tool.ts +72 -0
  37. package/src/core/shared/workspace-files.ts +76 -0
  38. package/src/core/testing/thread-host.ts +14 -0
  39. package/src/index.ts +2 -0
  40. package/src/kit/index.ts +2 -0
  41. package/src/kit/worker.ts +2 -0
  42. package/src/testing.ts +7 -1
@@ -1,9 +1,12 @@
1
1
  import {
2
+ buildSessionProjection,
2
3
  type ExtensionAPI,
3
4
  type ExtensionFactory,
4
5
  estimateTokens,
5
6
  type SessionBeforeCompactEvent,
7
+ type SessionEntry,
6
8
  type SessionManager,
9
+ type SessionProjection,
7
10
  SettingsManager,
8
11
  } from "@earendil-works/pi-coding-agent";
9
12
 
@@ -29,8 +32,14 @@ export type CompactionHistory = Pick<
29
32
  export interface LatestCompaction {
30
33
  id: string;
31
34
  engine: CompactionEngine;
32
- /** Estimated context the compaction left: its summary and the entries it kept. */
35
+ /**
36
+ * The context the compaction left, as a request sends it: system prompt and tools included.
37
+ * The first request after it reports this; until then, its summary and kept entries plus the
38
+ * fixed part the last request before it carried.
39
+ */
33
40
  contextTokens: number;
41
+ /** Whether a request after the compaction reported `contextTokens`, or it is still estimated. */
42
+ measured: boolean;
34
43
  }
35
44
 
36
45
  /** The extension's compactions record its `engine` in their details; Pi's own do not. */
@@ -44,6 +53,41 @@ export function compactionEngine(
44
53
  : "pi";
45
54
  }
46
55
 
56
+ /** What a request sent, as its reply's usage reports it: undefined for a reply without usable usage. */
57
+ function sentTokens(entry: SessionEntry | undefined): number | undefined {
58
+ if (entry?.type !== "message") return undefined;
59
+ const message = entry.message;
60
+ if (message.role !== "assistant") return undefined;
61
+ if (message.stopReason === "aborted" || message.stopReason === "error")
62
+ return undefined;
63
+ const { input, cacheRead, cacheWrite } = message.usage;
64
+ const sent = input + cacheRead + cacheWrite;
65
+ return sent > 0 ? sent : undefined;
66
+ }
67
+
68
+ function estimated(messages: SessionProjection["messages"]): number {
69
+ let tokens = 0;
70
+ for (const message of messages) tokens += estimateTokens(message);
71
+ return tokens;
72
+ }
73
+
74
+ /**
75
+ * The part of a request that is not the conversation, system prompt and tool definitions, as the
76
+ * last reply before `index` reported it: what that request sent less the estimate of its messages.
77
+ */
78
+ function fixedTokens(branch: readonly SessionEntry[], index: number): number {
79
+ for (let at = index - 1; at >= 0; at--) {
80
+ const sent = sentTokens(branch[at]);
81
+ if (sent === undefined) continue;
82
+ const messages = buildSessionProjection(
83
+ branch.slice(0, at),
84
+ branch[at - 1]?.id ?? null,
85
+ ).messages;
86
+ return Math.max(0, sent - estimated(messages));
87
+ }
88
+ return 0;
89
+ }
90
+
47
91
  /** The branch's latest compaction, measured from the session as it stands now. */
48
92
  export function latestCompaction(
49
93
  history: CompactionHistory,
@@ -56,16 +100,22 @@ export function latestCompaction(
56
100
  const upToCompaction = new Set(
57
101
  branch.slice(0, index + 1).map((each) => each.id),
58
102
  );
59
- let contextTokens = 0;
103
+ const engine = compactionEngine(entry.details, extensionEngine);
104
+ for (const after of branch.slice(index + 1)) {
105
+ const sent = sentTokens(after);
106
+ if (sent !== undefined)
107
+ return { id: entry.id, engine, contextTokens: sent, measured: true };
108
+ }
109
+ let messages = 0;
60
110
  for (const projected of history.buildSessionProjection().entries) {
61
111
  if (!upToCompaction.has(projected.sourceEntry.id)) continue;
62
- for (const message of projected.messages)
63
- contextTokens += estimateTokens(message);
112
+ messages += estimated(projected.messages);
64
113
  }
65
114
  return {
66
115
  id: entry.id,
67
- engine: compactionEngine(entry.details, extensionEngine),
68
- contextTokens,
116
+ engine,
117
+ contextTokens: messages + fixedTokens(branch, index),
118
+ measured: false,
69
119
  };
70
120
  }
71
121
 
@@ -124,12 +174,15 @@ export class CompactionTiers {
124
174
  this.#extensionEngine = extensionEngine;
125
175
  }
126
176
 
127
- /** The latest compaction; measured once per compaction, since pi checks before every request. */
177
+ /**
178
+ * The latest compaction; pi checks before every request, so it is measured once per compaction
179
+ * after a request reports it, and estimated again until one does.
180
+ */
128
181
  latest(): LatestCompaction | undefined {
129
182
  const branch = this.#history.getBranch();
130
183
  const entry = branch.findLast((each) => each.type === "compaction");
131
184
  if (!entry) return undefined;
132
- if (this.#cached?.id !== entry.id) {
185
+ if (this.#cached?.id !== entry.id || !this.#cached.latest.measured) {
133
186
  const latest = latestCompaction(this.#history, this.#extensionEngine);
134
187
  if (!latest) return undefined;
135
188
  this.#cached = { id: entry.id, latest };
@@ -107,6 +107,8 @@ export class ConversationSessions {
107
107
  }
108
108
  : {},
109
109
  this.#tiers,
110
+ // Owner sessions have no shell workspace but still send files from their own directory.
111
+ agents ? agents.workDir : this.#factory.cwd(undefined),
110
112
  );
111
113
  this.#gates.set(key, gate);
112
114
  this.#restored.delete(key);
@@ -1,7 +1,7 @@
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, ownerWords } from "../../identity.ts";
4
+ import { type OwnerIdentity, ownerWords, PRONOUNS } from "../../identity.ts";
5
5
  import type { OwnerAnswer, OwnerQuestion } from "../../interactions/prompts.ts";
6
6
  import { toolText } from "../../shared/tool-result.ts";
7
7
  import type { PromptSlot } from "../prompt-slot.ts";
@@ -10,12 +10,14 @@ export const ASK_USER_TOOL = "ask_user";
10
10
 
11
11
  /** The tool's result: what the owner chose and wrote. */
12
12
  export function answerText(
13
- answer: OwnerAnswer | undefined,
13
+ answer: OwnerAnswer | "pending" | undefined,
14
14
  owner: OwnerIdentity,
15
15
  ): string {
16
16
  const o = ownerWords(owner);
17
+ if (answer === "pending")
18
+ return `${o.name} has not answered yet. The card stays open, so do not ask again and do not wait for it. End your turn now: say briefly that you are waiting for ${o.his} answer on the card. When ${o.he} answers, a new turn brings you the answer and you continue from there.`;
17
19
  if (!answer)
18
- return `No answer: ${o.name} did not answer in time. Go on without it if you safely can, otherwise ask in your reply.`;
20
+ return `No answer: ${o.name} did not answer. Go on without it if you safely can, otherwise ask in your reply.`;
19
21
  const lines = [
20
22
  ...(answer.choices.length > 0
21
23
  ? [`${o.name} chose: ${answer.choices.join("; ")}`]
@@ -25,6 +27,15 @@ export function answerText(
25
27
  return lines.length > 0 ? lines.join("\n") : `${o.name} chose nothing.`;
26
28
  }
27
29
 
30
+ /** The turn a late answer starts: the question it answers and what was chosen and written. */
31
+ export function lateAnswerText(
32
+ question: string,
33
+ answer: OwnerAnswer,
34
+ by: string,
35
+ ): string {
36
+ return `(${by} answered your earlier ask_user question on its card, after your turn had ended. Continue from where you stopped, with this answer.)\nQuestion: ${question}\n${answerText(answer, { name: by, pronouns: PRONOUNS.they })}`;
37
+ }
38
+
28
39
  /** Registers ask_user, which asks the owner on a card in the turn's channel and waits. */
29
40
  export function askUserExtension(
30
41
  slot: PromptSlot,
@@ -37,7 +48,7 @@ export function askUserExtension(
37
48
  pi.registerTool({
38
49
  name: ASK_USER_TOOL,
39
50
  label: `Ask ${o.name}`,
40
- description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. ${o.He} has 30 minutes; after that the result says there was no answer.`,
51
+ description: `Ask ${o.name} a question on a card with buttons in this channel and wait for ${o.his} answer, within this turn. Use it when you need ${o.his} decision to continue, instead of ending your turn with a question. The card should read on its own, so put the context ${o.he} needs in the question too. Offer options when the choices are known; set multi when several may apply and allow_other to let ${o.him} write ${o.his} own answer. If ${o.he} does not answer soon, the result says so and the card stays open: then end your turn saying you are waiting, and ${o.his} answer comes in a new turn.`,
41
52
  parameters: Type.Object(
42
53
  {
43
54
  question: Type.String({ minLength: 1, maxLength: 1500 }),
@@ -88,6 +99,12 @@ export function askUserExtension(
88
99
  messages().askTitle(slot.asker),
89
100
  question,
90
101
  signal,
102
+ {
103
+ late: (late) =>
104
+ late.kind === "question"
105
+ ? lateAnswerText(p.question, late.answer, late.by)
106
+ : undefined,
107
+ },
91
108
  ),
92
109
  addressee?.() ?? owner,
93
110
  ),
@@ -1,3 +1,5 @@
1
+ import { statSync } from "node:fs";
2
+ import { resolve } from "node:path";
1
3
  import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
4
  import type {
3
5
  HeldCall,
@@ -6,7 +8,7 @@ import type {
6
8
  import { type HoldCheck, type HoldContext, higherTier } from "../../holds.ts";
7
9
  import { messages } from "../../i18n/index.ts";
8
10
  import { type OwnerIdentity, ownerWords } from "../../identity.ts";
9
- import type { Prompts } from "../../interactions/prompts.ts";
11
+ import type { LateAnswer, Prompts } from "../../interactions/prompts.ts";
10
12
  import type { Speaker } from "../../speakers.ts";
11
13
  import type { ToolTiers } from "../../tool-tiers.ts";
12
14
  import type { PromptSlot } from "../prompt-slot.ts";
@@ -70,16 +72,22 @@ export class ConfirmationGate {
70
72
  #speaker: Pick<Speaker, "id" | "principalId"> | undefined;
71
73
  #pending: PendingConfirmation | undefined;
72
74
  #approved: HeldCall[] = [];
75
+ /** Calls approved on their cards after the turn that asked stopped waiting; each runs once, in any turn. */
76
+ #lateApproved: HeldCall[] = [];
73
77
  readonly #holds: HoldCheck;
74
78
  readonly #owner: OwnerIdentity;
75
79
  /** Whom the running turn's held actions and refusals speak of: the turn's speaker. */
76
80
  #addressee: OwnerIdentity;
77
81
  readonly #context: HoldContext;
78
82
  readonly #tiers: ToolTiers | undefined;
83
+ /** Where the session's relative file paths resolve, for the sizes a card shows. */
84
+ readonly #fileRoot: string | undefined;
79
85
 
80
86
  /**
81
87
  * `holds` decides which calls wait for the owner. With a workspace, the session has a shell,
82
88
  * and its writes outside the workspace and the context's scratch dir are held too.
89
+ * `fileRoot` is the session's working directory, where a card resolves relative file paths;
90
+ * it defaults to the workspace.
83
91
  */
84
92
  constructor(
85
93
  holds: HoldCheck,
@@ -87,8 +95,10 @@ export class ConfirmationGate {
87
95
  pending?: PendingConfirmation,
88
96
  context: HoldContext = {},
89
97
  tiers?: ToolTiers,
98
+ fileRoot?: string,
90
99
  ) {
91
100
  this.#tiers = tiers;
101
+ this.#fileRoot = fileRoot ?? context.workspace;
92
102
  this.#holds = holds;
93
103
  this.#owner = owner;
94
104
  this.#addressee = owner;
@@ -155,11 +165,18 @@ export class ConfirmationGate {
155
165
  if (!ask) return { reason: this.#record(call) };
156
166
  const answer = await ask.prompts.confirm(
157
167
  messages().confirmTitle(ask.asker),
158
- approvalCard(call),
168
+ approvalCard(call, this.#fileRoot),
159
169
  ask.signal,
160
170
  higherTier(this.#tiers?.minTier(call.tool), call.minTier),
171
+ { late: (late) => this.#late(call, late) },
161
172
  );
162
173
  if (answer === "approved") return { approvedOnCard: true };
174
+ if (answer === "pending") {
175
+ const o = ownerWords(this.#addressee);
176
+ return {
177
+ reason: `Awaiting ${o.name}'s approval: this would ${call.action}. Its card stays open, and the call did not run. Do not retry it or ask again in this turn. End your turn now: say briefly that you are waiting for ${o.his} approval on the card. When ${o.he} answers, a new turn tells you.`,
178
+ };
179
+ }
163
180
  if (answer === "declined") {
164
181
  const o = ownerWords(this.#addressee);
165
182
  return {
@@ -170,6 +187,18 @@ export class ConfirmationGate {
170
187
  return { reason: this.#record(call) };
171
188
  }
172
189
 
190
+ /**
191
+ * A late answer on a call's card: an approval lets the identical call run once, in whichever
192
+ * turn makes it next; returns the text of the turn that tells the model.
193
+ */
194
+ #late(call: HeldCall, late: LateAnswer): string | undefined {
195
+ if (late.kind !== "approval") return undefined;
196
+ if (!late.approved)
197
+ return `(${late.by} declined on its card the action you were waiting on: it would ${call.action}. It did not run; do not make the call again. Go on without it, or ask what ${late.by} wants instead.)`;
198
+ this.#lateApproved.push(call);
199
+ return `(${late.by} approved on its card the action you were waiting on: it would ${call.action}. Make this call now with exactly this input, and it runs once; then report its result.)\n- ${call.tool} ${call.input}`;
200
+ }
201
+
173
202
  /** The call when it needs the owner's confirmation and no approval releases it. */
174
203
  #needing(tool: string, input: Record<string, unknown>): HeldCall | undefined {
175
204
  const context = this.#context;
@@ -182,12 +211,14 @@ export class ConfirmationGate {
182
211
  action,
183
212
  ...(minTier ? { minTier } : {}),
184
213
  };
185
- const approved = this.#approved.findIndex(
186
- (c) => c.tool === call.tool && c.input === call.input,
187
- );
188
- if (approved !== -1) {
189
- this.#approved.splice(approved, 1);
190
- return undefined;
214
+ for (const released of [this.#approved, this.#lateApproved]) {
215
+ const approved = released.findIndex(
216
+ (c) => c.tool === call.tool && c.input === call.input,
217
+ );
218
+ if (approved !== -1) {
219
+ released.splice(approved, 1);
220
+ return undefined;
221
+ }
191
222
  }
192
223
  return call;
193
224
  }
@@ -229,13 +260,44 @@ export function confirmedTurnText(
229
260
  return `(${owner.name} approved the held actions below with this message. Make each call now with exactly this input.)\n${calls}\n\n${text}`;
230
261
  }
231
262
 
232
- /** What an approval card says: the held action's words, then the exact call. */
233
- export function approvalCard(call: HeldCall): string {
263
+ /**
264
+ * What an approval card says: the held action's words, the exact call, and each file it sends by
265
+ * path with the file's size now; relative paths resolve against `workspace`.
266
+ */
267
+ export function approvalCard(call: HeldCall, workspace?: string): string {
234
268
  const input =
235
269
  call.input.length > CARD_INPUT_CHARS
236
270
  ? `${call.input.slice(0, CARD_INPUT_CHARS)}…`
237
271
  : call.input;
238
- return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\``;
272
+ const files = pathFiles(call.input).map(
273
+ (path) => `\n-# ${messages().cardFile(path, fileSize(path, workspace))}`,
274
+ );
275
+ return `**${call.action}**\n-# \`${call.tool}\`\n\`\`\`json\n${input.replaceAll("```", "`\u200b``")}\n\`\`\`${files.join("")}`;
276
+ }
277
+
278
+ /** The paths of a call's `files` entries that name one. */
279
+ function pathFiles(input: string): string[] {
280
+ let parsed: unknown;
281
+ try {
282
+ parsed = JSON.parse(input);
283
+ } catch {
284
+ return [];
285
+ }
286
+ const files = isPlainObject(parsed) ? parsed.files : undefined;
287
+ if (!Array.isArray(files)) return [];
288
+ return files.flatMap((file: unknown) =>
289
+ isPlainObject(file) && typeof file.path === "string" ? [file.path] : [],
290
+ );
291
+ }
292
+
293
+ /** A file's size in bytes, or undefined when it cannot be read. */
294
+ function fileSize(path: string, workspace: string | undefined) {
295
+ try {
296
+ const info = statSync(resolve(workspace ?? "/", path));
297
+ return info.isFile() ? info.size : undefined;
298
+ } catch {
299
+ return undefined;
300
+ }
239
301
  }
240
302
 
241
303
  /** Without a slot, or with an empty one, every call that needs confirmation is held. */
@@ -46,10 +46,12 @@ export class PromptSlot {
46
46
  const prompts = this.#prompts;
47
47
  if (!prompts) return undefined;
48
48
  return {
49
- confirm: (title, message, signal, minTier) =>
50
- this.#waiting(() => prompts.confirm(title, message, signal, minTier)),
51
- ask: (title, question: OwnerQuestion, signal) =>
52
- this.#waiting(() => prompts.ask(title, question, signal)),
49
+ confirm: (title, message, signal, minTier, wait) =>
50
+ this.#waiting(() =>
51
+ prompts.confirm(title, message, signal, minTier, wait),
52
+ ),
53
+ ask: (title, question: OwnerQuestion, signal, wait) =>
54
+ this.#waiting(() => prompts.ask(title, question, signal, wait)),
53
55
  };
54
56
  }
55
57
 
@@ -111,6 +111,8 @@ export const revisionsKey = (plan: SessionPlan) =>
111
111
  /** The core's own extensions of one session, which the plan's contributions surround. */
112
112
  export interface CoreExtensions {
113
113
  readAttachment: ExtensionFactory;
114
+ /** `attach_file`, for a session with a workspace. */
115
+ attachFile?: ExtensionFactory;
114
116
  confirmationGate: ExtensionFactory;
115
117
  askUser: ExtensionFactory;
116
118
  selfCompactGuard: ExtensionFactory;
@@ -139,6 +141,9 @@ export function sessionExtensions(
139
141
  return [
140
142
  ...contributed(plan.tools),
141
143
  { name: "read-attachment", factory: core.readAttachment },
144
+ ...(core.attachFile
145
+ ? [{ name: "attach-file", factory: core.attachFile }]
146
+ : []),
142
147
  { name: "confirmation-gate", factory: core.confirmationGate },
143
148
  { name: "ask-user", factory: core.askUser },
144
149
  { name: "self-compact-guard", factory: core.selfCompactGuard },
@@ -23,6 +23,10 @@ import {
23
23
  type TransientTask,
24
24
  } from "../sessions.ts";
25
25
  import { activeToolsExtension } from "../shared/active-tools.ts";
26
+ import {
27
+ ATTACH_FILE_TOOL,
28
+ attachFileExtension,
29
+ } from "../shared/attach-file-tool.ts";
26
30
  import { packageDir } from "../shared/package-dir.ts";
27
31
  import { readAttachmentExtension } from "../shared/read-attachment-tool.ts";
28
32
  import { type Speaker, THE_SPEAKER } from "../speakers.ts";
@@ -130,6 +134,8 @@ export class SessionFactory {
130
134
  ...currentToolNames(selection.tools, this.#options.logger),
131
135
  COMPACT_TOOL,
132
136
  ASK_USER_TOOL,
137
+ // Registered only by a session with a workspace.
138
+ ATTACH_FILE_TOOL,
133
139
  ...groups
134
140
  .filter((group) => selection.groups.includes(group.name))
135
141
  .flatMap((group) => group.tools),
@@ -201,12 +207,14 @@ export class SessionFactory {
201
207
 
202
208
  /**
203
209
  * Logs who compacted a conversation, the compaction extension or Pi's summary, by how much, and the context size
204
- * that compacts it next, so a move to the hard ceiling shows.
210
+ * that compacts it next, so a move to the hard ceiling shows. `contextAfter` is the context the tiers measure,
211
+ * system prompt and tools included, which `tokensAfter`, the conversation alone, leaves out.
205
212
  */
206
213
  #logCompaction(
207
214
  channel: ChannelKey,
208
215
  event: CompactionEnd,
209
216
  session: AgentSession,
217
+ tiers: CompactionTiers,
210
218
  ): void {
211
219
  const { logger } = this.#options;
212
220
  const trigger = event.reason === "manual" ? "self" : event.reason;
@@ -233,6 +241,7 @@ export class SessionFactory {
233
241
  ),
234
242
  tokensBefore: event.result.tokensBefore,
235
243
  tokensAfter: event.result.estimatedTokensAfter,
244
+ contextAfter: tiers.latest()?.contextTokens,
236
245
  nextCompactionAt:
237
246
  model &&
238
247
  model.contextWindow -
@@ -332,6 +341,14 @@ export class SessionFactory {
332
341
  },
333
342
  conversation,
334
343
  addressee,
344
+ ...(agents
345
+ ? {
346
+ workspace: {
347
+ workspace: cwd,
348
+ ...(agents.scratchDir ? { scratchDir: agents.scratchDir } : {}),
349
+ },
350
+ }
351
+ : {}),
335
352
  attachmentDir,
336
353
  get memory() {
337
354
  return state.memory;
@@ -353,6 +370,9 @@ export class SessionFactory {
353
370
  additionalExtensionPaths: this.link().extensionPaths,
354
371
  extensionFactories: sessionExtensions(this.plan, context, {
355
372
  readAttachment: readAttachmentExtension(attachmentDir),
373
+ ...(context.workspace
374
+ ? { attachFile: attachFileExtension(context.workspace) }
375
+ : {}),
356
376
  confirmationGate: confirmationGateExtension(gate, slot),
357
377
  askUser: askUserExtension(slot, addressee, () =>
358
378
  turnAddressee(
@@ -406,7 +426,7 @@ export class SessionFactory {
406
426
  await this.#awaitTools(session, awaited);
407
427
  session.subscribe((event) => {
408
428
  if (event.type === "compaction_end")
409
- this.#logCompaction(channel, event, session);
429
+ this.#logCompaction(channel, event, session, tiers);
410
430
  });
411
431
  return Object.assign(state, { session });
412
432
  }
@@ -83,6 +83,11 @@ export interface SessionContext {
83
83
  * it reads `"none"` for that turn alone.
84
84
  */
85
85
  memory: "speaker" | "none";
86
+ /**
87
+ * The shared workspace of a session with a shell, and its scratch dir: the roots its shell writes
88
+ * in without a hold, and where its tools may read a file by path. Absent for a session without one.
89
+ */
90
+ workspace?: { workspace: string; scratchDir?: string };
86
91
  /** Where the session's conversation keeps the files people attached; absent when the session keeps none. */
87
92
  attachmentDir?: string;
88
93
  /** The person the session's running turn is for; undefined between turns. */
@@ -124,6 +129,7 @@ export interface SessionTool {
124
129
  /** Extensions the core places itself; no contribution may take their names. */
125
130
  const CORE_EXTENSIONS = [
126
131
  "read-attachment",
132
+ "attach-file",
127
133
  "confirmation-gate",
128
134
  "ask-user",
129
135
  "self-compact-guard",
@@ -0,0 +1,72 @@
1
+ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import {
4
+ attachReplyFile,
5
+ REPLY_FILE_LIMITS,
6
+ ReplyFileError,
7
+ } from "../reply-files.ts";
8
+ import { toolError, toolText } from "./tool-result.ts";
9
+ import {
10
+ findWorkspaceFile,
11
+ readWorkspaceFile,
12
+ WorkspaceFileError,
13
+ type WorkspaceRoots,
14
+ } from "./workspace-files.ts";
15
+
16
+ export const ATTACH_FILE_TOOL = "attach_file";
17
+
18
+ /** Registers `attach_file`, which posts a file from the session's disk with the turn's reply. */
19
+ export function attachFileExtension(roots: WorkspaceRoots): ExtensionFactory {
20
+ return (pi) => {
21
+ pi.registerTool({
22
+ name: ATTACH_FILE_TOOL,
23
+ label: "Attach file",
24
+ description:
25
+ "Show a file you have on disk, such as a picture you made, by attaching it to your reply in this conversation. Give its path inside the workspace or the scratch dir; never read an image or base64 a file to show it.",
26
+ parameters: Type.Object(
27
+ {
28
+ path: Type.String({
29
+ minLength: 1,
30
+ maxLength: 4096,
31
+ description:
32
+ "The file's path inside the workspace or the scratch dir.",
33
+ }),
34
+ filename: Type.Optional(
35
+ Type.String({
36
+ minLength: 1,
37
+ maxLength: 255,
38
+ description:
39
+ "The name it is posted under, without a path; defaults to the path's base name.",
40
+ }),
41
+ ),
42
+ },
43
+ { additionalProperties: false },
44
+ ),
45
+ execute: async (_toolCallId, params) => {
46
+ try {
47
+ const file = await findWorkspaceFile(params.path, roots);
48
+ if (file.size > REPLY_FILE_LIMITS.maxFileBytes)
49
+ throw new ReplyFileError(
50
+ `${params.path} is ${file.size} bytes; a reply file may contain at most ${REPLY_FILE_LIMITS.maxFileBytes} bytes.`,
51
+ );
52
+ const data = await readWorkspaceFile(
53
+ file,
54
+ REPLY_FILE_LIMITS.maxFileBytes,
55
+ );
56
+ const name = params.filename ?? file.name;
57
+ attachReplyFile({ name, data });
58
+ return toolText(
59
+ `${name} (${data.byteLength} bytes) will appear with your reply.`,
60
+ );
61
+ } catch (error) {
62
+ if (
63
+ error instanceof WorkspaceFileError ||
64
+ error instanceof ReplyFileError
65
+ )
66
+ return toolError(`Not attached: ${error.message}`);
67
+ throw error;
68
+ }
69
+ },
70
+ });
71
+ };
72
+ }
@@ -0,0 +1,76 @@
1
+ import { readFile, realpath, stat } from "node:fs/promises";
2
+ import { basename, isAbsolute, relative, resolve } from "node:path";
3
+
4
+ /** Where a session may read files by path: its shared workspace and its scratch dir. */
5
+ export interface WorkspaceRoots {
6
+ workspace: string;
7
+ scratchDir?: string;
8
+ }
9
+
10
+ /** A file refused by path; its message names the path and is safe to show the model. */
11
+ export class WorkspaceFileError extends Error {
12
+ override name = "WorkspaceFileError";
13
+ }
14
+
15
+ /** A local file found inside the roots, not read yet. */
16
+ export interface WorkspaceFile {
17
+ /** The path as the caller gave it. */
18
+ path: string;
19
+ /** Where it resolves, symlinks followed. */
20
+ realPath: string;
21
+ /** Its base name, the default name it is sent under. */
22
+ name: string;
23
+ size: number;
24
+ }
25
+
26
+ /** Whether `child` is `parent` or inside it. */
27
+ function within(parent: string, child: string): boolean {
28
+ const rel = relative(parent, child);
29
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
30
+ }
31
+
32
+ async function realRoot(root: string): Promise<string | undefined> {
33
+ return realpath(root).catch(() => undefined);
34
+ }
35
+
36
+ /**
37
+ * Finds a regular file by path, relative paths against the workspace, and refuses one that does
38
+ * not resolve inside the workspace or the scratch dir, so a symlink counts by its target.
39
+ */
40
+ export async function findWorkspaceFile(
41
+ path: string,
42
+ roots: WorkspaceRoots,
43
+ ): Promise<WorkspaceFile> {
44
+ const given = resolve(roots.workspace, path);
45
+ let real: string;
46
+ try {
47
+ real = await realpath(given);
48
+ } catch {
49
+ throw new WorkspaceFileError(`${path} does not exist.`);
50
+ }
51
+ const allowed = await Promise.all(
52
+ [roots.workspace, roots.scratchDir]
53
+ .filter((root): root is string => root !== undefined)
54
+ .map(realRoot),
55
+ );
56
+ if (!allowed.some((root) => root !== undefined && within(root, real)))
57
+ throw new WorkspaceFileError(
58
+ `${path} is outside the workspace${roots.scratchDir ? " and the scratch dir" : ""}; only files inside ${roots.scratchDir ? "them" : "it"} can be sent.`,
59
+ );
60
+ const info = await stat(real);
61
+ if (!info.isFile()) throw new WorkspaceFileError(`${path} is not a file.`);
62
+ return { path, realPath: real, name: basename(given), size: info.size };
63
+ }
64
+
65
+ /** Reads a file `findWorkspaceFile` found; a file that grew past `maxBytes` since is refused. */
66
+ export async function readWorkspaceFile(
67
+ file: WorkspaceFile,
68
+ maxBytes: number,
69
+ ): Promise<Uint8Array> {
70
+ const data = new Uint8Array(await readFile(file.realPath));
71
+ if (data.byteLength > maxBytes)
72
+ throw new WorkspaceFileError(
73
+ `${file.path} is ${data.byteLength} bytes, over the ${maxBytes} allowed.`,
74
+ );
75
+ return data;
76
+ }
@@ -5,6 +5,7 @@ import {
5
5
  DispatchThreads,
6
6
  type ThreadHost,
7
7
  } from "../discord/dispatch-threads.ts";
8
+ import type { InterimMessage } from "../domain/interim.ts";
8
9
  import { silentLogger } from "../log.ts";
9
10
 
10
11
  /** A ThreadHost that records what happens; parents in `hostless` cannot host threads. */
@@ -14,6 +15,8 @@ export class FakeThreadHost implements ThreadHost {
14
15
  readonly opened: { parentId: string; name: string; id: string }[] = [];
15
16
  readonly posts: { threadId: string; text: string }[] = [];
16
17
  readonly closed: string[] = [];
18
+ /** Edits of messages `send` posted, in order; each also changes its entry in `posts`. */
19
+ readonly edits: { threadId: string; text: string }[] = [];
17
20
  readonly hostless = new Set<string>();
18
21
  failOpen = false;
19
22
  #next = 900;
@@ -35,6 +38,17 @@ export class FakeThreadHost implements ThreadHost {
35
38
  this.posts.push({ threadId, text });
36
39
  }
37
40
 
41
+ async send(threadId: string, text: string): Promise<InterimMessage> {
42
+ const post = { threadId, text };
43
+ this.posts.push(post);
44
+ return {
45
+ edit: async (change) => {
46
+ post.text = change;
47
+ this.edits.push({ threadId, text: change });
48
+ },
49
+ };
50
+ }
51
+
38
52
  async close(threadId: string): Promise<void> {
39
53
  this.closed.push(threadId);
40
54
  }
package/src/index.ts CHANGED
@@ -162,11 +162,13 @@ export { SYSTEM_PRINCIPAL } from "./core/identity/principal-store.ts";
162
162
  export type {
163
163
  Approval,
164
164
  AskOption,
165
+ LateAnswer,
165
166
  OwnerAnswer,
166
167
  OwnerPrompts,
167
168
  OwnerQuestion,
168
169
  PromptScope,
169
170
  Prompts,
171
+ PromptWait,
170
172
  } from "./core/interactions/prompts.ts";
171
173
  export { promptScope } from "./core/interactions/prompts.ts";
172
174
  export type { JudgeModel } from "./core/judging/model-judge.ts";