pi-roundtable 0.9.9 → 0.9.10

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 CHANGED
@@ -5,6 +5,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.10] - 2026-10-10
9
+
10
+ ### Added
11
+
12
+ - `ToolTurn.origin`, `TurnRequest.origin`, `ConversationTurnInput.origin` and `SessionContext.origin()`: where a turn's request came from, as the transport that carried it reported it, such as the browser origin of a web chat connection (`https://chat.example.com`). A tool's `run` and its `hold` read it from the `ToolTurn` they receive, so a tool can label what it creates (an issue's source line) and fail closed when it is undefined. A claim passes it to `context.turns.run({ ..., origin })`; the runtime keeps it with the turn's speaker and clears it when the turn ends. Only the transport sets it, never the message text or the model. A turn whose channel gives none (Discord, a schedule, an agent's turn, the CLI) leaves it undefined, and `testPlugin().runTool` takes `origin` in its options. Additive: nothing changes for a claim or a tool that does not use it, and `SessionContext.origin` is optional on the type so a context built before this still type-checks.
13
+
8
14
  ## [0.9.9] - 2026-10-10
9
15
 
10
16
  ### Added
package/docs/plugins.md CHANGED
@@ -589,6 +589,10 @@ The host logs counts only (`files`, `bytes`, `unattributedBytes`), never a file
589
589
  `run` receives the arguments, already typed, and the turn: the speaker, the channel, the agent, an abort signal, `workspace`, `attachFile`, and `attachment`.
590
590
  `turn.workspace` is `{ workspace, scratchDir? }` in a session with a shared workspace (an agent's), the same roots as `SessionContext.workspace`, and undefined elsewhere: a tool that makes a file can save it there, under the scratch dir when there is one, and name its path, so the model sends it on with `attach_file` or a `discord_send_message` file `path` instead of carrying its bytes.
591
591
  It returns the text the model reads.
592
+ `turn.origin` is where the turn's request came from, as the transport that carried it reported it, and undefined when the channel gives none: the web chat sets it to the browser origin its connection opened with (`https://chat.example.com`, `chrome-extension://<id>`), a Discord message, a schedule or an agent's turn leave it undefined.
593
+ Only a channel's transport sets it, never the message text or the model, so a tool may write it on a record it creates, such as an issue's source line; a tool that needs it fails closed when it is undefined.
594
+ A claim passes it to `context.turns.run` as `origin`, and the hold of the same call sees it too, since `hold` receives the same `ToolTurn`.
595
+
592
596
  `turn.attachment(file)` opens a file someone attached to the conversation, by the name the turn's `## Attachments` block lists, and returns `{ file, name, contentType, size, bytes() }`: a tool passes a user's file on without the model carrying its bytes.
593
597
  Only the conversation's own files open; a path, a hidden name, or a missing file throws a `ToolRefusal` the model reads, and so does a file the host's [retention period](#retention-removing-used-files-after-a-period) removed, which says so.
594
598
  Throw `ToolRefusal` for a call the model should correct; any other error fails the call.
@@ -746,7 +750,7 @@ export const cleanup = definePlugin({
746
750
  #### A hold that looks things up
747
751
 
748
752
  A hold may need something it must fetch as the speaker before it can describe the call, such as the display name of the entity an id stands for, read with the speaker's own credentials.
749
- A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel` and the `agent` of the turn that makes the call.
753
+ A tool's `hold` may then return a promise and takes the turn as a second argument: the `ToolTurn` its `run` receives, with the `speaker`, the `channel`, the `agent` and the `origin` of the turn that makes the call.
750
754
 
751
755
  ```ts
752
756
  defineTool({
@@ -1861,6 +1865,7 @@ The factory gets the session's `SessionContext`, fixed when the session is made:
1861
1865
  - `addressee` is whom tool descriptions should name: the primary owner exactly as configured in their own private conversation, another person by their name and pronouns (their name alone when they gave none), and `THE_SPEAKER` in a shared conversation, or a private one of someone the host knows by no name yet.
1862
1866
  - `memory` is `"none"` for a persona that declares it, and `"speaker"` otherwise; a memory tool of your own loads nothing for `"none"`. A task the session runs with `runTask` keeps its conversation's `memory`.
1863
1867
  - `speaker()` is the running turn's speaker, undefined between turns; a tool reads it when it runs, never when the session is made, since a session outlives the turn it is built in.
1868
+ - `origin()` is where the running turn's request came from, as `TurnRequest.origin` says, and undefined between turns and for a turn whose transport gives none; read it when the tool runs, like `speaker()`. It is optional on the type so a context built before 0.9.10 still type-checks.
1864
1869
 
1865
1870
  The core's Jev compactor is one such extension:
1866
1871
 
@@ -2012,7 +2017,7 @@ Call `context.turns.run(input)` inside your claim's queue task to run a turn in
2012
2017
  It shows typing and the stop control on the channel's surface, runs the turn, and emits `turnStarted` and `turnEnded` with the turn's `kind`.
2013
2018
  It catches runtime errors as failed results and posts the answer, failure notice, or stopped notice through the surface.
2014
2019
  Supply `reply(result)` to handle the reply yourself.
2015
- `input` has `channel`, `kind`, `text`, `speaker`, and optionally `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, `reply`, and `conversation`.
2020
+ `input` has `channel`, `kind`, `text`, `speaker`, and optionally `origin` (where the request came from, as your claim's transport knows it, such as a connection's browser origin; tools read it as `turn.origin`, so never fill it from message text), `attachments`, `selection` (by default the plugins' `agentSelection`), `steerable`, `interactive`, `confirmed`, `reply`, and `conversation`.
2016
2021
  It rejects with `NotLinkedError` during `setup`, and with a `PluginError` on a host whose runtime plugin has not provided a runtime.
2017
2022
 
2018
2023
  Before each turn runs, `context.turns.run` records its conversation in the host's registry, `CONVERSATIONS`: at the first turn its key, surface, kind, visibility, owner, and title, and at every later turn only that it was active.
@@ -2955,7 +2960,7 @@ It returns:
2955
2960
  | `contribution` | What the plugin added, as the host would collect it: `tools`, `prompt`, `seeds`, `events`, `services`, `http`, and the rest |
2956
2961
  | `tools`, `tiers` | The tool names, and the table that says what tier each needs |
2957
2962
  | `holds` | The plugin's `holdRules` chained as the host links them (`holdChain`): `holds(tool, input, { workspace?, scratchDir? })` returns the description of a call that must be approved first, or `undefined`; `holds.inTurn(tool, input, context, turn)` asks them as a turn does, and returns a promise when a hold looks things up |
2958
- | `runTool(name, args, { speaker, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2963
+ | `runTool(name, args, { speaker, origin, channel, workspace }?)` | Runs a tool the way an agent's turn would, in the channel (default `test:1`) for the speaker, and returns the text the model reads; `origin` is the turn's `turn.origin`, and `workspace` (`{ workspace, scratchDir? }`) is the session's `turn.workspace` |
2959
2964
  | `files` | Accepted files from `runTool`, recorded as `{ channel, file: ReplyFile }`; inject a surface with `supportsFiles: true` and pass its channel to test attachment tools |
2960
2965
  | `events` | The events the plugin itself reported through `context.events`, and those of `context.turns` |
2961
2966
  | `conversations`, `turns`, `surfaces` | What the plugin sees as `context.conversations`, `context.turns`, and `context.surfaces`, for a test to drive its claims |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "description": "A plugin-driven Pi agent server for one owner, reached through Discord or the web",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -26,6 +26,14 @@ export interface ToolTurn {
26
26
  /** The agent whose turn it is. */
27
27
  agent: AgentTurnScope | undefined;
28
28
  signal: AbortSignal | undefined;
29
+ /**
30
+ * Where the turn's request came from, as its transport reported it, such as the browser origin
31
+ * of a web chat connection (`https://chat.example.com`); undefined for a turn whose channel gives
32
+ * none, such as a Discord message or a schedule. Only the transport sets it, never the message
33
+ * text or the model, so a tool may label a record with it; a tool that needs one fails closed
34
+ * when it is undefined.
35
+ */
36
+ origin?: string;
29
37
  /**
30
38
  * The session's shared workspace and scratch dir, where a tool may save a file for the model to
31
39
  * pass on by path; undefined for a session without a workspace.
@@ -30,6 +30,13 @@ export interface TurnRequest {
30
30
  * starts a turn on someone's behalf gets theirs from `IDENTITY.speakerFor`.
31
31
  */
32
32
  speaker: Speaker;
33
+ /**
34
+ * Where the request came from, as the transport that carried it knows: the browser origin of a
35
+ * web chat connection, such as `https://chat.example.com`. A channel with no such thing leaves it
36
+ * out. Only a channel's transport sets it, never the message text or the model; a tool reads it
37
+ * as `ToolTurn.origin`.
38
+ */
39
+ origin?: string;
33
40
  /**
34
41
  * Who the conversation belongs to, as the host records it: `private` to one principal, or
35
42
  * `shared` by whoever its claim admits, which it is taken to be when absent. A private
@@ -29,6 +29,12 @@ export interface ConversationTurnInput {
29
29
  text: string;
30
30
  /** Who the turn is for; its tier limits the tools. */
31
31
  speaker: Speaker;
32
+ /**
33
+ * Where the request came from, as the claim's transport knows it, such as a web chat
34
+ * connection's browser origin; tools read it as `ToolTurn.origin`. Leave it out for a turn the
35
+ * transport gives none, and never fill it from message text.
36
+ */
37
+ origin?: string;
32
38
  /** Files and images the message carries. */
33
39
  attachments?: TurnAttachments;
34
40
  /** The turn's tools; by default the plugins' `agentSelection`, read at the turn. */
@@ -184,6 +190,7 @@ export function conversationTurns(
184
190
  },
185
191
  text: input.text,
186
192
  speaker,
193
+ ...(input.origin === undefined ? {} : { origin: input.origin }),
187
194
  ...(conversation ? { conversation } : {}),
188
195
  ...(input.attachments
189
196
  ? { attachments: input.attachments }
@@ -71,10 +71,10 @@ export class PiAgentRuntime implements AgentRuntime {
71
71
  readonly #tiers: ToolTiers;
72
72
  readonly #factory: SessionFactory;
73
73
  readonly #sessions: ConversationSessions;
74
- /** The tier and speaker of each conversation's running turn. */
74
+ /** The tier, speaker and origin of each conversation's running turn. */
75
75
  readonly #turns = new Map<
76
76
  ChannelKey,
77
- { tier: Tier; speaker: Speaker; withheld: boolean }
77
+ { tier: Tier; speaker: Speaker; origin?: string; withheld: boolean }
78
78
  >();
79
79
  /** Each conversation's turn in progress, which the owner may steer or stop. */
80
80
  readonly #running = new Map<ChannelKey, SteerableRun>();
@@ -87,6 +87,7 @@ export class PiAgentRuntime implements AgentRuntime {
87
87
  this.#tiers = options.toolTiers ?? toolTiers();
88
88
  this.#factory = new SessionFactory(options, {
89
89
  speaker: (channel) => this.#turns.get(channel)?.speaker,
90
+ origin: (channel) => this.#turns.get(channel)?.origin,
90
91
  withholdsMemory: (channel) => this.#turns.get(channel)?.withheld === true,
91
92
  runTask: (scope, task) => this.#runTask(scope, task),
92
93
  });
@@ -192,7 +193,12 @@ export class PiAgentRuntime implements AgentRuntime {
192
193
  loadsMemory(registered),
193
194
  );
194
195
  this.#memoryTurns.set(key, markPrivateMemory);
195
- this.#turns.set(key, { tier, speaker: request.speaker, withheld });
196
+ this.#turns.set(key, {
197
+ tier,
198
+ speaker: request.speaker,
199
+ ...(request.origin === undefined ? {} : { origin: request.origin }),
200
+ withheld,
201
+ });
196
202
  const missing = wanted.filter((name) => !registered.has(name));
197
203
  if (missing.length > 0) {
198
204
  logger.warn(
@@ -73,6 +73,8 @@ import {
73
73
  export interface SessionFactoryDeps {
74
74
  /** The person the conversation's running turn is for. */
75
75
  speaker(channel: ChannelKey): Speaker | undefined;
76
+ /** Where the conversation's running turn came from, as its transport reported it. */
77
+ origin(channel: ChannelKey): string | undefined;
76
78
  /** Whether the conversation's running turn loads no memory, as the agent server's option keeps a speaker out of it. */
77
79
  withholdsMemory(channel: ChannelKey): boolean;
78
80
  /**
@@ -355,6 +357,7 @@ export class SessionFactory {
355
357
  return state.memory;
356
358
  },
357
359
  speaker: () => this.#deps.speaker(turnKey),
360
+ origin: () => this.#deps.origin(turnKey),
358
361
  runTask: (task) =>
359
362
  this.#deps.runTask({ turn: turnKey, home: channel }, task),
360
363
  };
@@ -92,6 +92,12 @@ export interface SessionContext {
92
92
  attachmentDir?: string;
93
93
  /** The person the session's running turn is for; undefined between turns. */
94
94
  speaker(): Speaker | undefined;
95
+ /**
96
+ * Where the running turn's request came from, as `TurnRequest.origin` says; undefined between
97
+ * turns and for a turn whose transport gives none. A context built before 0.9.10 has no such
98
+ * method, which reads as undefined.
99
+ */
100
+ origin?(): string | undefined;
95
101
  /** Runs a task beside this session, under its confirmation gate. */
96
102
  runTask(task: TransientTask): Promise<string>;
97
103
  }
@@ -15,11 +15,13 @@ export function toolTurn(
15
15
  context: SessionContext,
16
16
  signal: AbortSignal | undefined,
17
17
  ): ToolTurn {
18
+ const origin = context.origin?.();
18
19
  return {
19
20
  speaker: context.speaker(),
20
21
  channel: context.turnChannel,
21
22
  agent: context.agent,
22
23
  signal,
24
+ ...(origin === undefined ? {} : { origin }),
23
25
  ...(context.workspace ? { workspace: context.workspace } : {}),
24
26
  attachFile: attachReplyFile,
25
27
  attachment: async (file) => {
package/src/testing.ts CHANGED
@@ -270,6 +270,8 @@ export interface TestPluginResult {
270
270
  /** `workspace` gives the session a workspace and scratch dir, as an agent's session has. */
271
271
  options?: {
272
272
  speaker?: Speaker;
273
+ /** Where the turn's request came from, as `ToolTurn.origin` shows it to the tool. */
274
+ origin?: string;
273
275
  channel?: ChannelKey;
274
276
  workspace?: SessionContext["workspace"];
275
277
  },
@@ -640,6 +642,7 @@ export async function testPlugin(
640
642
  ...(runOptions?.workspace ? { workspace: runOptions.workspace } : {}),
641
643
  attachmentDir: ownerAttachmentDir(attachmentDir, channel),
642
644
  speaker: () => runOptions?.speaker,
645
+ origin: () => runOptions?.origin,
643
646
  runTask: async () => {
644
647
  throw new Error("test session cannot run tasks");
645
648
  },