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.
Files changed (150) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +35 -11
  3. package/README.zh-TW.md +27 -10
  4. package/docs/migration-0.9.md +247 -0
  5. package/docs/plugins.md +606 -78
  6. package/examples/channel-context.test.ts +88 -0
  7. package/examples/channel-context.ts +62 -0
  8. package/examples/echo-runtime.test.ts +1 -0
  9. package/examples/fake-surface.test.ts +69 -6
  10. package/examples/fake-surface.ts +73 -7
  11. package/examples/headless.ts +5 -1
  12. package/examples/prechecks.test.ts +1 -0
  13. package/examples/principals.test.ts +60 -0
  14. package/examples/principals.ts +48 -0
  15. package/examples/prompt.test.ts +1 -1
  16. package/examples/study-room.test.ts +29 -1
  17. package/examples/study-room.ts +16 -19
  18. package/examples/support-desk.test.ts +3 -1
  19. package/examples/tools.test.ts +6 -1
  20. package/package.json +5 -2
  21. package/src/cli/checks/database.ts +11 -0
  22. package/src/cli/checks/identity.ts +225 -0
  23. package/src/cli/checks/model.ts +21 -3
  24. package/src/cli/cli.ts +115 -1
  25. package/src/cli/config-edit.ts +83 -13
  26. package/src/cli/doctor.ts +7 -0
  27. package/src/cli/line-diff.ts +72 -0
  28. package/src/cli/principal.ts +412 -0
  29. package/src/cli/project.ts +5 -0
  30. package/src/cli/report.ts +12 -3
  31. package/src/cli/upgrade-rewrite.ts +102 -0
  32. package/src/cli/upgrade-source.ts +430 -0
  33. package/src/cli/upgrade.ts +176 -0
  34. package/src/core/agents/agent-claim.ts +56 -34
  35. package/src/core/agents/team-turns.ts +8 -4
  36. package/src/core/builtin/agent-server.ts +9 -7
  37. package/src/core/builtin/discord-admin.ts +4 -1
  38. package/src/core/builtin/discord.ts +71 -13
  39. package/src/core/builtin/modules.ts +140 -85
  40. package/src/core/builtin/stores.ts +40 -21
  41. package/src/core/config/access.ts +229 -0
  42. package/src/core/config/adapters.ts +64 -1
  43. package/src/core/config/config.ts +78 -41
  44. package/src/core/contract/channel-context.ts +53 -0
  45. package/src/core/contract/channels.ts +77 -9
  46. package/src/core/contract/runtime.ts +23 -7
  47. package/src/core/contract/surface.ts +22 -6
  48. package/src/core/conversations/conversation-registry.ts +14 -0
  49. package/src/core/conversations/conversation-store.ts +13 -0
  50. package/src/core/define-roundtable.ts +41 -11
  51. package/src/core/discord/agent-panel.ts +2 -2
  52. package/src/core/discord/card-audience.ts +148 -0
  53. package/src/core/discord/channel-context.ts +348 -0
  54. package/src/core/discord/connection.ts +15 -3
  55. package/src/core/discord/direct-channel.ts +54 -0
  56. package/src/core/discord/discord-owners.ts +133 -0
  57. package/src/core/discord/discord-surface.ts +69 -14
  58. package/src/core/discord/fresh-marker.ts +24 -0
  59. package/src/core/discord/inbound-message.ts +21 -7
  60. package/src/core/discord/interaction-module.ts +11 -1
  61. package/src/core/discord/owner-cards-fixture.ts +80 -0
  62. package/src/core/discord/owner-cards.ts +58 -97
  63. package/src/core/discord/owner-command.ts +27 -13
  64. package/src/core/discord/stop-button.ts +4 -4
  65. package/src/core/domain/conversation.ts +7 -2
  66. package/src/core/domain/errors.ts +5 -0
  67. package/src/core/domain/ports.ts +27 -2
  68. package/src/core/host.ts +27 -8
  69. package/src/core/i18n/cards.ts +8 -4
  70. package/src/core/identity/access-policy.ts +227 -0
  71. package/src/core/identity/actor-facts.ts +65 -0
  72. package/src/core/identity/caching-principal-store.ts +111 -0
  73. package/src/core/identity/config-owner.ts +145 -0
  74. package/src/core/identity/contact.ts +27 -0
  75. package/src/core/identity/identity-plugin.ts +56 -0
  76. package/src/core/identity/identity-schema.ts +282 -0
  77. package/src/core/identity/identity-service.ts +489 -0
  78. package/src/core/identity/identity-view.ts +77 -0
  79. package/src/core/identity/plugin-identities.ts +109 -0
  80. package/src/core/identity/principal-reads.ts +38 -0
  81. package/src/core/identity/principal-store.ts +478 -0
  82. package/src/core/identity/principal-tables.ts +53 -0
  83. package/src/core/identity/seen-throttle.ts +57 -0
  84. package/src/core/identity/ulid.ts +36 -0
  85. package/src/core/identity.ts +34 -5
  86. package/src/core/interactions/prompts.ts +127 -0
  87. package/src/core/modules/background/background-turns.ts +56 -17
  88. package/src/core/modules/background/personal-target.ts +50 -0
  89. package/src/core/modules/delegation/delegate.ts +28 -14
  90. package/src/core/modules/delegation/delegator.ts +23 -7
  91. package/src/core/modules/memory/owner-memory.ts +123 -50
  92. package/src/core/modules/notify/notify.ts +53 -13
  93. package/src/core/modules/notify/session-notify.ts +110 -0
  94. package/src/core/modules/schedules/prechecks.ts +8 -2
  95. package/src/core/modules/schedules/schedule-prechecks.ts +4 -1
  96. package/src/core/modules/schedules/schedule-store.ts +51 -20
  97. package/src/core/modules/schedules/schedule-tools.ts +122 -12
  98. package/src/core/modules/schedules/scheduler.ts +43 -2
  99. package/src/core/modules/schedules/schedules.ts +43 -14
  100. package/src/core/plugin.ts +48 -0
  101. package/src/core/presence/direct-channels.ts +104 -0
  102. package/src/core/registry/contributions.ts +63 -3
  103. package/src/core/routing/channel-router.ts +218 -17
  104. package/src/core/routing/conversation-turns.ts +38 -8
  105. package/src/core/routing/surface-port.ts +32 -2
  106. package/src/core/routing/system-turns.ts +29 -0
  107. package/src/core/runtime/bridge-guard.ts +225 -0
  108. package/src/core/runtime/compaction-tiers.ts +5 -1
  109. package/src/core/runtime/conversation-sessions.ts +124 -12
  110. package/src/core/runtime/extensions/ask-user.ts +1 -1
  111. package/src/core/runtime/extensions/confirmation-gate.ts +28 -14
  112. package/src/core/runtime/extensions/private-memory.ts +447 -0
  113. package/src/core/runtime/pending-confirmation-store.ts +30 -5
  114. package/src/core/runtime/pi-agent-runtime.ts +135 -134
  115. package/src/core/runtime/preflight-tools.ts +47 -0
  116. package/src/core/runtime/prompt-slot.ts +4 -4
  117. package/src/core/runtime/reader-history.ts +129 -0
  118. package/src/core/runtime/runtime-plugin.ts +50 -5
  119. package/src/core/runtime/runtime-types.ts +40 -12
  120. package/src/core/runtime/session-conversation.ts +143 -0
  121. package/src/core/runtime/session-factory.ts +115 -15
  122. package/src/core/runtime/session-scope.ts +78 -0
  123. package/src/core/runtime/steerable-run.ts +9 -0
  124. package/src/core/runtime/tool-call-scope.ts +43 -0
  125. package/src/core/runtime/turn-answer.ts +30 -1
  126. package/src/core/services.ts +27 -1
  127. package/src/core/sessions.ts +30 -0
  128. package/src/core/speakers.ts +21 -19
  129. package/src/core/testing/background.ts +31 -0
  130. package/src/core/testing/fixture-database.ts +55 -0
  131. package/src/core/testing/map-identity.ts +76 -0
  132. package/src/core/testing/module-sessions.ts +134 -0
  133. package/src/core/testing/modules.ts +108 -21
  134. package/src/core/testing/owner.ts +17 -0
  135. package/src/core/testing/surface-contract.ts +118 -41
  136. package/src/core/testing/test-host.ts +79 -16
  137. package/src/core/testing/tool-set.ts +32 -0
  138. package/src/core/tool-set.snapshot.json +4 -4
  139. package/src/core/tool-tiers.ts +57 -3
  140. package/src/discord/index.ts +8 -0
  141. package/src/index.ts +47 -8
  142. package/src/kit/compaction.ts +13 -0
  143. package/src/kit/index.ts +10 -0
  144. package/src/kit/support.ts +1 -1
  145. package/src/testing.ts +55 -16
  146. package/templates/.env.example +1 -1
  147. package/templates/adapters/web/README.md +7 -0
  148. package/templates/adapters/web/roundtable.config.ts +14 -10
  149. package/templates/roundtable.config.ts +12 -1
  150. package/src/core/domain/owner-prompts.ts +0 -45
package/CHANGELOG.md CHANGED
@@ -5,6 +5,108 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.0] - 2026-10-09
9
+
10
+ Principal and identity attribution replace implicit owner authority.
11
+ Ordinary single-owner Discord configurations remain accepted, but direct runtime callers, custom background turns, and web integrations must follow [Migrating to 0.9](docs/migration-0.9.md).
12
+ The guarantee isolates principal memory exchanges and private conversations, not shared credentials or arbitrary model disclosures in public replies.
13
+
14
+ ### Added
15
+
16
+ - Reader-record replay checks for core and sandbox Pi sessions: `MEMORY_TURN_ENTRY`, `recordMemoryTurn`, `carriesMemory`, and `bridgeHistoryHidesMemory` in `pi-roundtable/kit` persist each turn's reader and whether it carried private memory, including prompt-only memory, malformed or unfinished memory calls, and memory-loaded workers. A claude-bridge request refuses before any provider call if raw history (including compacted turns) carried another reader's private memory; public turns without private memory remain shareable. SYSTEM stays recorded as SYSTEM and counts as primary owner for the core's replay check. A corrupt reader record (or an empty reader) refuses every later bridge turn of its conversation, fail closed; start a new conversation to recover (documented in the migration guide). `bridgeHistoryHidesMemory(branch, reader)` takes two parameters: the primary-owner attribution of SYSTEM and of pre-scope memory stays inside the core, and `carriesMemory` names a public message type. In opt-in Pi sandbox mode, `memory.visibility` defaults to `"private"`; set `"shared"` explicitly for a party-wide prompt block, including on a 0.8 upgrade, so it does not taint each participant's reader record.
17
+
18
+ - `discord.agentMemory` (`"everyone"`, the default, or `"owners"`; `AgentServerOptions.memory`, handed to the runtime as `AgentSessions.memory`): with `"owners"`, a turn of a speaker below the owner tier in an agent's channel or group loads no memory, with no memory block in its prompt, no memory tools, and no memory-loaded workers, while owners' turns are unchanged. The guest's turn records no private memory, so it never makes the claude-bridge reader-record guard refuse the owner or SYSTEM afterwards. The default keeps today's behavior.
19
+
20
+ - Opt-in Pi sandbox workers declare `privateTo` and `readerRecords` capabilities at readiness, and report retained private history after a host restart. A private prompt block, known private history, or an actual `privateTo` host-tool result requires those capabilities; public-only hosts still work with older images. Missing support refuses the entire turn, names the image to rebuild, and never forwards a private tool payload to an older worker.
21
+
22
+ #### Identity and access
23
+
24
+ - The built-in `identity` plugin, registered first in every host: the tables `principals`, `principal_identities`, and `principal_roles` (ledger ids `identity/principals`, `identity/principals-claimable`, and `identity/principals-plugin-links`), and a backfill that runs at every boot (`identity/backfill`) and makes a principal of the same id for the configured owner and every person id stored in `owner_memory`, `schedules`, `conversations`, and `held_actions` (a held action that names its `principal_id` for the hold it holds is skipped: its `speaker_id` is an actor id; so is any id a linked identity is known by, its subject or `<provider>:<subject>`, since this version wrote that row). A principal it makes may be claimed once by the person of that id, unless it is a configured owner's, which is never claimable, even after the configuration names another owner. 0.8's `remote-mcp` speaker, the owner writing through an outside agent over MCP, gets no principal of its own: it is linked as `legacy:remote-mcp` to the primary owner's principal, so the schedules it created stay the owner's, at the owner tier. A carried-over author of schedules who is not a configured owner is recorded as seen at the upgrade, at the highest tier they scheduled at but at most the highest tier the access rules could give them (never owner), so their schedules keep running for `backgroundStaleDays` until they are seen again. It only inserts, so no existing row changes, and it logs one line with what it made. A test that lists the host's plugins or migrations now finds `identity` and its four migrations first.
25
+ - `IDENTITY`, the identity service the `identity` plugin provides: `resolve(facts, { conversation }?)` turns what a surface reports about a person (`ActorFacts`: `provider`, `subject`, `name`, `roles` written `<surface>:role:<name>`, optional `surface`, `space`, and `legacyId`, the 0.8 speaker id) into a `Speaker` with its `principalId`, or undefined when the access rules serve no one by them. A linked identity is its principal's; an unlinked one first claims the principal of its 0.8 id, so a person carried over from 0.8 keeps their memory, schedules, and conversations; a principal the backfill could only name by its id takes the name the facts carry as it is claimed, while one it named, such as from a schedule, keeps that name. A principal is claimed at most once: only one the backfill made, before any identity is linked to it, and never one holding the owner role, so an identity unlinked by the CLI or dropped from the configuration does not claim it back; otherwise someone the rules admit gets a new `p_<ulid>` principal. A disabled principal is no one; the owner tier comes only from the configured owners or a role the CLI grants, never from a role a surface reports. The tier is the higher of the principal's lasting roles and what the rules give the facts on this contact. Someone refused is not claimed or admitted; a linked person refused is recorded as seen at no tier, except by facts that lack the roles the rules decide by, such as a Discord DM, which neither refuse nor lower anyone on record. The service is read-only to plugins, and what it returns is a frozen copy: `principal(id)`, `list()`, `identities(principalId)`, `roles(principalId)`, `tierOf(principalId)` (the tier of the lasting roles), `owners()`, and `speakerFor(principalId, tier?)`, for a turn started on someone's behalf at their tier or lower (the tier last seen for someone whose tier comes only from the rules, refused after `backgroundStaleDays` or once a contact of theirs is refused), complete it. Principals, links, and roles are written only by the configuration and the `roundtable principal` CLI, and `speakerFor` refuses `SYSTEM_PRINCIPAL`, whose turns only the core starts. Reads are reused for 30 seconds, so a change another process makes is seen within that time. At every boot the configured owners are made, linked, and granted the owner role; an owner or an identity the configuration no longer names loses what the configuration gave it, and a configured owner's identity linked to someone else stops the boot with a `ConfigError`. Types: `IdentityService`, `ActorFacts`, `AccessRules`, `AccessOwner`, `AccessTier`, `Principal`, `IdentityLink`, `IdentityRef`, `LinkSource`, `RoleGrant`, `RoleSource`; values: `IDENTITY`, `SYSTEM_PRINCIPAL`, `IdentityError`.
26
+ - The `access` configuration key: `access: { owners: [{ name, pronouns?, principal?, identities? }], admins?, members?, provisioning?, backgroundStaleDays? }`. Identities are written `<provider>:<subject>` (`discord:<user id>`, `token:remote-mcp`, and for OpenID Connect `oidc:<issuer>:<subject>`, the web chat's 0.8 speaker id); roles `<surface>:role:<name>`, such as `discord:role:<role id>` or `web:role:App.User`; `everyone` is `true` for every surface or a list of surfaces. The first owner is the primary owner and needs a `principal` in 0.9, such as the old `owner.id`; with Discord configured they also need a `discord:` identity, which the Discord parts act for. `provisioning` is `"admitted"` (default) or `"linked"`, which serves only identities already linked (by the CLI or as a configured owner's) and claims no 0.8 principal; `backgroundStaleDays` defaults to 30. `access` with `owner` or `speakers`, or neither, is a configuration error. `roundtable init` writes `access` in both templates. Types: `AccessConfig`, `AccessOwnerConfig`, `AccessTierConfig`.
27
+ #### Operator commands
28
+
29
+ - `roundtable upgrade [--write]` rewrites `roundtable.config.ts` in the 0.9 form: `owner` and `speakers` as the `access` they mean (the owner's `principal` is the old `owner.id`, their identity `discord:<id>` on a host with Discord and none without; users `discord:<id>`, roles `discord:role:<id>`, `everyone` as `["discord"]`), and the top-level `discord` as `adapters: [discord({ ... })]` with its import. It shows a diff and writes only with `--write`, after loading the rewrite beside the file and checking that the host would serve the same owners, admins, and members with the same Discord settings. The check loads the file as the host does, so it needs the environment the host runs with: when the file does not load as it is, such as with secrets kept outside `.env`, the preview says it was not checked and `--write` refuses (exit 1) until that environment is set; `--write --unchecked` writes it anyway, with a warning. It keeps every comment and says which it moved, changes no other file, and refuses, naming the line, what it cannot read with confidence, such as an owner built elsewhere or a spread configuration. A second run finds nothing to change.
30
+ - `roundtable principal list | show | create | link | unlink | grant | revoke | disable | enable` lists and changes the principals, their identities, and their roles in the host's database. `list` prints each identity as `access` and `link` take it, `<provider>:<subject>`, and says of a principal with none whether its 0.8 id still links its own at first contact; `link` says when it uses up that claim, which no later `unlink` gives back; the owner role is granted only here and in the configuration; `grant` of a role the configuration already gives changes nothing and says so, and `revoke` refuses it, so it stays the configuration's to take away; `unlink` refuses an identity the configuration lists under that owner, whoever linked it (the configuration, a first contact, the CLI, or a 0.8 claim), and also when the configuration's schema rejects it, since a running host would admit it as someone new and the next start would fail on it, and says to remove it from `access.owners[*].identities` instead; it takes one the configuration moved to another owner, as a start that stops on it asks, and says to restart the host with that configuration, unlinking again any new `p_…` principal that takes the identity meanwhile. The owners are read by the configuration's schema alone, so a plugin or model runtime that fails does not pass for a configuration that does not load. A `<principal>` that is both a principal's id and an identity linked to another principal, as a 0.8 web user's id can be, is refused as ambiguous, naming both. A running host sees a change within 30 seconds, which every change says.
31
+ - `roundtable doctor` checks `access` (a warning when `everyone: true` admits everyone on every surface of a host with more than one) and the principals: the backfill the next start makes, as a dry run printed as the start logs it, a configured owner's identity linked to another principal, which stops the start, and a disabled owner. A warning, printed with `!`, does not fail the doctor.
32
+ #### Plugin and conversation contracts
33
+
34
+ - `RoundtablePlugin.identities`: identities a plugin's own credentials stand for, each a `PluginIdentity` (`{ identity, principal? }`, the identity written `token:<subject>`, by default the primary owner's; only the `token` provider is accepted, since a surface's identity such as `discord:` or `oidc:` is a person's own account, linked by `access.owners` or the CLI). At every start the `identity` plugin links each one to its principal under the new link source `plugin` (ledger id `identity/principals-plugin-links`), moves one bound to another principal than at the last start, and unlinks one no plugin declares any more; an identity that does not parse or is not a `token:` identity, one two plugins declare, the system principal, a principal that does not exist, or an identity linked to someone else by the configuration, the CLI, or a first contact stops the start with a `ConfigError` that names what to change. A declared identity is never admitted or claimed at a first contact, and its link does not use up the principal's 0.8 claim, since a plugin's credential is not the person's own identity. For the same reason its link never tells which principal a configured owner is: an owner in `access.owners` that lists a declared identity is known by its `principal` or its other identities, and when that is not the principal the plugin binds the identity to on that start (its `principal`, or else the primary owner), or nothing else tells who the owner is, the start stops with a `ConfigError` saying to remove the identity from that owner or bind the plugin to them; `roundtable doctor` reports the same. Whom the identity was bound to at an earlier start does not matter, so binding the plugin to that owner lets the next start through, moving the identity to them. `IDENTITY.principalOf(identity)` reads the principal an identity is linked to. `roundtable principal list` and `show` name the plugin that declares a `plugin` link, or say no plugin declares it any more and the next start unlinks it; `unlink` refuses one a plugin declares, or one it cannot tell because the configuration does not load, and says to change the plugin's options instead.
35
+ - `Speaker.principalId`, the principal the speaker is, equal to `id` for a person carried over from 0.8. It is required: a plugin that builds a `Speaker` itself gives it, from `IDENTITY.speakerFor` when it starts a turn for someone. `OWNER_SPEAKER` from `pi-roundtable/testing` has it, and the speakers of `speakerPolicy(map)` have their own id as their principal.
36
+ - `InboundMessage.actor`: who wrote the message, as the surface reports them (`ActorFacts`); the Discord surface fills it, with the author's roles as `discord:role:<id>` in a server and none known in a DM, and the web chat with an OpenID user as subject under the provider `oidc:<base64url issuer>`, their roles as `<surface>:role:<name>`, and their 0.8 speaker id as `legacyId`, so a web user the access rules serve claims their 0.8 principal under that provider. A surface that leaves it out has it read from `authorId`, `authorName`, and `authorRoleIds` with its prefix as the provider, with a `deprecated` warning once per surface; that goes away in 1.0.
37
+ - `InboundMessage.speaker`: who the author is to the host, set by the router alone before any claim admits the message, through `IDENTITY`: undefined when the access rules serve no one by them, when they cannot be resolved (logged), and for bots and integrations, which are not resolved; a surface's own value is dropped. The author is recorded, such as linking them at their first contact or recording them as seen, only once a claim admits the message, so messages no claim takes link or make no one (an already linked author the access rules refuse is still recorded as seen at no tier as the message is resolved, at most once every few minutes), and what a claim admitted runs even when that record fails; a channel's messages still reach their claim in the order they came. `testPlugin` resolves authors through a given `IDENTITY`, and otherwise only the `owner` is anyone, at the owner tier.
38
+ - `CONVERSATIONS.adopt(key, principalId)` makes a conversation recorded `shared` with no principal, as 0.8 recorded every turn run without `conversation`, private to that principal, for a plugin migrating what its 0.8 version left, such as remote-mcp's sessions; it never changes a private conversation or one with a principal, is safe to repeat and to race, and resolves what is stored (undefined for an unknown key). A turn's registration still never changes what the first one recorded. A stand-in `ConversationRegistry` needs the method too.
39
+ - `Prompts`, `PromptScope`, and `promptScope`: a running turn's prompts are answered by those its scope names, made with `promptScope(speaker, visibility)`: `principalId`, `speakerId`, `tier`, and `escalate`, `owners` in a shared conversation and `none` in a private one. The speaker answers their own question and approves a call at its `minTier`; where the scope escalates to the owners every owner may answer too, and a call above the speaker's tier is theirs alone; where it escalates to no one, such a call resolves `expired` at once, shown to no one. `ChatSurface.prompts(channel, scope?)` receives the scope; `SurfacePort.prompts` and `RuntimeDeps.prompts` take it, and still take 0.8's `Speaker`, read as the speaker of a shared conversation, with a deprecation warning once per host until 1.0. `OwnerPrompts` is a deprecated alias of `Prompts` until 1.0.
40
+ - `TurnRequest.conversation` (`TurnConversation`): who the turn's conversation belongs to, `private` to a `principalId` or `shared`; `context.turns` gives it as the registry keeps it, so a private conversation's prompts stay its person's at every turn. The Pi runtime scopes an interactive turn's prompts by it.
41
+ - `PendingConfirmation.principalId`: the principal of the speaker whose turn held the calls, stored with them in `held_actions.principal_id` beside the hold it belongs to, `principal_held_at` (ledger id `runtime/held-actions-principal`), so a confirming message from another identity of theirs approves them after a restart too; calls held before 0.9, or held by 0.8 over this version's after a rollback, are matched by `speakerId`.
42
+ - `describeSurfaceContract` checks two more things: in a private conversation an owner, another principal, cannot answer for the person (given `SurfaceContractSubject.owner`, a `SurfaceContractAnswerer` like `stranger`), and a private conversation's approval above a speaker below the owner tier resolves `expired` at once without being shown. The example `FakeSurface` keeps its prompts open until someone they are for answers them, with `answer(id, who, approved)`, and runs the contract.
43
+
44
+ - `CommandGuard.allows(actor)`, a promise of whether the actor is an owner now: the primary owner by user id, and every other owner principal by their Discord identity, checked through `IDENTITY` each time, and a stranger never resolved. `commandGuard` takes `identity` for it, and `DiscordActor` is what it accepts: an interaction, or anything with `user.id`.
45
+
46
+ #### Channel context
47
+
48
+ - Channel context: a turn for a message in an agent's channel also reads the channel messages posted since the assistant's last post there, including those that did not address it, appended to the turn's text after the message (and after the replied-to and forwarded messages) as a delimited block that says they were not addressed to the assistant, each message with its author as separate `from`, `id` and `role` attributes (names lose `"'=<>(),`, and every `<`, also the full-width `<`, in a message's text becomes `‹`, so a message cannot close the block or forge another), so it lives in the conversation's history, not in the system prompt. It reads Discord as the turn starts: the `fetch` messages before the addressed one (default 50, at most 100), only those after the assistant's latest post there (its bot user's or one of its application's webhooks'), without the assistant's own posts, the addressed message, empty messages, and every message it handed over or that addressed the assistant before in that channel (remembered in memory per channel); other bots are kept and marked, the host's owners marked; consecutive messages of one author at least `similarity` alike (default 0.8) count as one, the later kept; the newest `keep` (default 15) are kept, oldest first; mentions read as names, stickers and files as text with their names and links, never downloaded; a message is cut at `messageChars` (default 500), another bot's at `botMessageChars` (default 80), and near repeats are judged by at most a message's first 500 characters; an option given as `undefined` counts as not given. A Discord error is logged and the turn runs without it. Group rounds read none, since they carry what was said since their last turn, and direct messages never do. It is public channel text: not private memory, never tagged `privateTo`, and the turn's reader stays the person who addressed the assistant; people with no tier are included as channel text but do not become speakers.
49
+ - The configuration key `discord.channelContext`: `false` turns channel context off for the host, the agent server and every claim; otherwise `{ fetch?, keep?, similarity?, messageChars?, botMessageChars? }`, the host's defaults, which a call's options override. Prompt snapshot (h) records an agent turn with channel context; (a) through (g) are unchanged.
50
+ - A divider after a restart: when a conversation is started over (`conversations.startFresh`, whichever claim owns the channel) in a Discord server channel or thread, the core posts one short public message there as the bot, after the claim's `startFresh` succeeded, so channel context's window, which ends at the assistant's last public post, no longer reads what was said before the restart. The owner's ephemeral reply is unchanged; direct messages and other surfaces get none, and a failed post is logged as a warning while the restart still counts. The configuration key `discord.freshMarker` sets the text (default `─── new conversation ───`) or, as `false`, posts none; a surface contributed by a plugin may implement the optional `ChatSurface.markFresh(channel)`.
51
+ - `DISCORD.channelContext(message, options?)`, for a plugin's claim to opt in: call it once per answered message inside the turn's `run`, and append what it returns with `withChannelContext(text, context)`. It resolves undefined in a direct message, on another surface, when the host turned it off, when nothing new was said, and when Discord cannot be read, and never rejects for those; an invalid option (`fetch`, `keep`, `messageChars` and `botMessageChars` not positive integers, `similarity` outside 0 to 1) rejects, a programming error. Types `ChannelContext` (`{ messages, text }`), `ChannelContextMessage`, and `ChannelContextOptions`, and values `CHANNEL_CONTEXT_DEFAULTS` and `withChannelContext`, from `pi-roundtable/discord`. A stand-in `DISCORD` service needs the member; `fakeDiscord({ channelContext })` answers it, by default with no context.
52
+
53
+ #### Personal background work and delivery
54
+
55
+ - `PERSONAL_TARGET`, the host's own background target: every person's conversations whose claim answers background turns. The core's `modules` plugin contributes it on every host, with or without Discord, where 0.8's agent server contributed it, so a plugin's conversations on a host without Discord schedule and delegate too once its claim answers background turns. Its name stays `"owner"`, as 0.8 stored it, and its limits are 0.8's. `OWNER_TARGET` is the same target, deprecated until 1.0; a plugin that contributes a target named `"owner"` itself now clashes with it.
56
+ - Per-person limits: `BackgroundTarget.schedules.perPrincipal` holds each person to that many of a target's schedules across all their conversations (0.8's `remote-mcp` schedules count as the primary owner's), and `BackgroundTarget.delegation.maxRunningPerPrincipal` to that many delegated tasks running at once. Both are optional, and `PERSONAL_TARGET` sets neither: the configuration's new `background: { perPrincipal: { schedules?, delegations? } }` (`BackgroundConfig`, `PerPrincipalLimits`) sets them on the host's personal target, each a whole number from 1, and left out they stay unset, as in 0.8. A host that serves many people sets both. `ScheduleToolContext` (`pi-roundtable/kit`) takes `principalOf`, which tells the principal a creator id stands for, `visibility`, and a store with `all` and `createWithin` where the target counts per person. `ScheduleStore.createWithin(schedule, { creators, max })` counts a person's schedules and stores the new one under a lock on the creator, so creates at once in many conversations stay within `perPrincipal`; a stand-in `ScheduleStore` needs the method too.
57
+ - Direct channels: `Contribution.directChannels` takes a `DirectChannelProvider` (`{ name, label, reaches(principalId), deliver? }`), a way to reach a person on their own, such as a chat network's direct messages or a web inbox; two of one name are a `PluginError` naming both plugins. `context.directChannels` (`DirectChannels`) reaches a person through the first provider, in contribution order, that reaches them: `providers()`, `reach(principalId)` (a `DirectReach`, or undefined), and `notify(principalId, text)`, false when none reaches them; it is linked once every plugin is set up, as `conversations` is. The Discord plugin contributes `discord`, which reaches a principal's direct messages through a Discord identity linked to them, the primary owner's configured one first, so a single owner is reached in the same direct messages as in 0.8. `Notifier` (`pi-roundtable/kit`) is what sends such a notice; `OwnerNotifier`, which reaches the primary owner alone, is deprecated until 1.0.
58
+ #### Session scope and system reports
59
+
60
+ - [Migrating to 0.9](docs/migration-0.9.md), including rollback and exact prompt-snapshot differences, and a tested `examples/principals.ts` in the plugin guide's new Principals and access chapter.
61
+ - `SessionContext.conversation` (`SessionConversation`): whom a session's conversation serves, fixed when the session is made: `private` to a `principalId`, with the `principal` when the host knows them, or `shared`. An agent's session is shared; another is as `TurnRequest.conversation` names it, else as the conversation registry recorded it, else shared, never the owner's by default. The Pi runtime reads the registry and `IDENTITY` for it. A session's history records whom it served: when the conversation comes to serve someone else (another principal, or private where it was shared, or shared where it was private), the open session is dropped with the actions held in it and its history archived, as `startFresh` does, so the new session replays none of it, after a restart too; a history from before this version, which records no one, carries on. Changed tools or skills still rebuild a session with its history. `runtime.recentTranscript` reads a conversation's history without opening a session, so reading it before a turn decides nothing, and gives none recorded for someone else than the conversation serves now, nor an open session's made for someone else. `SessionContext.addressee` is whom tool descriptions name, and `SessionContext.memory` is `"none"` for a persona that declares `Persona.memory: "none"` (`"speaker"` by default), whose conversations then load no memory tools and no memory in the prompt; `LinkedSessions.personaMemory(kind)` reads it. A plugin or test that builds a `SessionContext` itself gives the three.
62
+ - `ChannelClaim.takesSystemReports`, optional and true when left out: a claim whose conversations are each private to the person who opened them, and so refuse the host's own turns (`SYSTEM_PRINCIPAL`), says `false`, and a host whose `ops.conversation` names a conversation it owns does not start, with a `ConfigError` saying its conversations take no system error reports and to name a shared conversation or use `ops.agent`, instead of logging every report as unanswered. `ConversationPort.takesSystemReports(channel)` reads it; a stand-in `ConversationPort` needs the method too.
63
+
64
+ ### Changed
65
+
66
+ - The opt-in Pi sandbox's host tool protocol adds `PiToolResponse.privateTo?: string`; the broker preserves it and the worker records `details.privateTo`, including on failed tool results. Pass admitted `author.principalId` to `PiSandboxRuntime`/`PiTurnRequest` (optional; legacy actor-id fallback), exposed to host tools as `PiHostContext.speaker.principalId` and worker extensions as `PiTurnContext.authorPrincipalId`. These independently-created Pi sessions now apply core request projection and private compaction, including the host compactor's kept tail. Custom private results hide their paired call arguments too, regardless of tool name, and split call/result groups stay redacted across compaction boundaries. Bridge sandbox turns refuse inaccessible exchanges in the persisted raw branch even after Pi compaction; no primary-owner legacy exception is available there. The sealed default and raw workspace sharing are unchanged. Rebuild the Pi worker image with the upgraded core/sandbox; tagging does not retroactively sanitize existing summaries or protect public replies/files.
67
+ - `pi-roundtable/kit` exports `memoryProjection`, `hidesPrivateExchange`, and `MemoryView` for independent shared Pi sessions. `MemoryView` contains only `shared` and `reader`; guard-only legacy attribution is internal, and public projection helpers ignore extra guard fields at runtime. `summaryProjection` accepts optional surrounding `history`, and `privateCompaction` optional `additionalMessages`, for private exchanges split between summary, prefix and kept context.
68
+ - Official package integrations now use core identity: the web console requires a linked owner identity (an unlinked actor receives 403, even with one owner; `legacy:` is refused; old actor-less verifiers retain warned primary-owner compatibility); webchat removes its `access`, `webAccess`, and `WebAccessMap` in favor of top-level `access`, principal-owned quotas/connections, personal background turns, and a private notice inbox (`notice` frames and REST list/read endpoints), while keeping protocol v1 and refusing system report destinations through `takesSystemReports: false`; remote MCP's token binds to the primary owner by default or an explicit principal and adopts its 0.8 sessions; coding's extra owner skills require a private principal holding owner and are rechecked on execution; sandbox ignores authors without a resolved tier and requires `SandboxSpeaker.principalId` at its runtime, without changing the sealed default's container protocol or channel-shared memory contract.
69
+ - Prompt snapshot compatibility with 0.8 is explicit: only (f) is byte-identical as a whole; (a)/(b)/(c) change only the notify tool name, (e) refuses the missing-speaker turn, and (d)/(g) replace owner wording with "the speaker" (with the per-speaker memory sentence added to (d)'s search/remove tools).
70
+ - `notify_owner` is now `notify`, and it notifies the person a conversation serves instead of the primary owner: the conversation's person in a private conversation, the turn's speaker in a shared one, through their direct channel, refusing when none reaches them; the host's own turns, such as a report's, still notify the primary owner. A session has it only when a plugin contributes a direct channel, and a private conversation only when one reaches its person. With only Discord its description is word for word 0.8's, so snapshots (a), (b), and (c) differ from 0.8 only in the tool's name, and the notice reaches the same direct messages. Until 1.0, `notify_owner` in a selection or a profile selects `notify`, a task's `exclude` and a plugin's `requiredTools` naming `notify_owner` name `notify`, and an operator's or a plugin's `toolTiers` entry for `notify_owner` applies to `notify` unless `notify` has its own; using the old name warns once per logger.
71
+ - `owner` and `speakers` are deprecated and go away in 0.10: they keep working, converted to the same `access` (the owner's principal is the old `owner.id`, which is also their Discord identity on a host with Discord, and `everyone` means everyone on Discord), so every author keeps the tier 0.8 gave them, and the host logs one `deprecated` warning. `owner` is now optional, required only without `access`.
72
+ - `speakerPolicy(map)` now evaluates the access rules its map means on one surface; who it admits, and at which tier, is unchanged.
73
+ - Who may talk to the agents, and who besides the owner may answer an approval card, now comes from `IDENTITY` alone, as resolved by the router, instead of a policy of their own read from the configuration: an owner the CLI granted is an owner there too. Who may speak on Discord, at which tier, is otherwise unchanged.
74
+ - A private conversation `context.turns` records is its speaker's principal's (`speaker.principalId`), not their surface id's; for a person carried over from 0.8 the two are the same.
75
+ - `context.turns.run` refuses, before the turn starts, a turn in a conversation recorded private to another principal than the speaker's; only its person and the host's own turns (`SYSTEM_PRINCIPAL`) speak in it. `notify` in such a turn refuses too, so a speaker's text never reaches the conversation's person.
76
+ - Memory is each principal's, never inferred from the tier: a private conversation reads and changes its person's memory, whoever speaks in it; a shared one each turn's speaker's (`speaker.principalId`, where 0.8 read the surface id); and the host's own turns (`SYSTEM_PRINCIPAL`) in a shared conversation read none, their memory tools answering so. A second owner, a web user at the owner tier, or an owner-tier remote MCP token bound to someone else reads their own, where 0.8 gave every owner-tier speaker the owner's memory. The primary owner's 0.8 rows still load: their principal is 0.8's owner id, and remote-mcp bound to them runs as that principal in a conversation private to them.
77
+ - A shared conversation's model requests carry the memory of the turn's reader alone, never another person's from its history. A memory tool's result records whose memory it holds (`details.privateTo`), and before each request the Pi runtime replaces a result of anyone else, in the host's own turns everyone's, with `(another person's private memory, hidden)`, and a memory result that does not say whose with a placeholder too, one recorded before this version or a failed call's, such as one whose arguments were refused, while a memory tool whose store fails records whose memory the call was for, so its person still reads it; the call such a result answers keeps its id and name but sends its arguments as `{ hidden: <the same placeholder> }`, in requests and in compaction summaries alike, and `memory_remove`'s answer that nothing matched, which repeats its text, records whose memory it searched too; the prompt states in the history collapse into one leading message of the current prompt, so a provider that keeps mid-conversation system messages no longer receives an earlier speaker's memory section, and the answers of earlier turns go without their reasoning (thinking, encrypted reasoning, tool calls' thought signatures, and the thought signature Gemini puts on an answer's text; OpenAI's signature of an answer's message, which names its id and phase, is no reasoning, but the message and call item ids OpenAI's Responses paired with the removed reasoning go with it, while each call keeps the id its result answers and each message its phase), which may restate the memory their turn read; the running turn keeps its own. A shared conversation's compaction summarizes its history without anyone's private memory, the prompt states, or the reasoning: the core hands the compaction extension placed through `session.compaction.wrap` the history as `summaryProjection` gives it, and Pi's own summary reads the same. `CompactionTiers.wrapCompactor` takes a third `prepare` argument for it, and `pi-roundtable/kit` exports `summaryProjection` and `privateCompaction`; a compactor of your own that reads a shared conversation's history otherwise must call `privateCompaction(event.preparation)` first. What people wrote and the assistant's replies stay shared, and a private conversation's requests are unchanged: the guarantee covers memory exchanges, a memory tool's call and result or a call whose worker loaded the reader's memory and its result, and earlier turns' reasoning, while anything else the model writes, into another tool's arguments or its reply, is public. A tool of your own whose result holds someone's memory returns the same `details`. A tool whose `session.runTask` worker loaded the reader's memory records its result as theirs the same way, and so do the calls it runs within, a nested call's outer call or the call whose worker started it, since the worker's report may repeat that memory; a call merely running beside it, its result public, is not.
78
+ - `claude-bridge` shared turns refuse with `AgentRunError` before any provider call when raw reader records show a turn that carried another reader's private memory, including prompt-only memory and its retained reasoning, memory-loaded workers, and malformed/truncated/aborted memory calls attributed to their turn. Explicit `privateTo` exchanges still restrict the reader. A turn without private memory never blocks other readers merely for speaking. For this check only SYSTEM reads as the primary owner and untagged pre-0.9 built-in memory results before the first scope record count as the primary owner's (later unowned exchanges without reader records still fail closed, while recorded malformed calls no longer lock out their own reader): old owner-only shared histories remain usable by the owner and SYSTEM, not guests. An agent turn checks and uses one copied model/thinking snapshot, so a hot change cannot switch providers after the guard. The same rule covers agent model switches and restored sessions, checking the persisted raw branch even for exchanges removed from Pi's active context by compaction; other providers are unaffected. Admission of several principals by configuration or stored roles becomes a startup warning, not a `ConfigError`, and doctor warns too. Admitting a guest elsewhere does not itself block owner turns. Bridge retains unfiltered history outside the request projection: SYSTEM can read and publicly repeat the owner's retained private exchanges/reasoning under the compatibility mapping, and public replies, untagged custom tools and old unrecorded prompts/reasoning/summaries remain potential disclosure paths. Use another provider or start fresh before sharing incompatible history. Disabling memory prevents future loading, but neither bypasses the guard nor erases recorded private history. Direct `PiAgentRuntimeOptions` now uses `memory?: boolean` instead of the former crowd-based `bridgeRefusal` callback.
79
+ - Tool descriptions name whom a conversation serves. In a private conversation the memory tools, `ask_user`, and `notify` name its person by their name and pronouns, so a single owner's own conversations read exactly as in 0.8 and a web member's no longer name the owner; in a shared conversation, such as a room a member speaks in, the memory tools and `ask_user` name "the speaker" and say each speaker has a memory of their own, as an agent's already did. `notify` there names the primary owner while only they can receive its notice (no other owner, and only owners may use it), and "the speaker" otherwise, read again before each turn, so an open session follows an owner granted or revoked meanwhile. A turn's approvals and answers name a second owner by their name, not the primary owner's. A conversation 0.8 recorded shared stays shared, its wording now the speaker's; a plugin whose 0.8 conversation was one person's adopts it.
80
+ - The Pi runtime refuses a turn, before the model is asked, in a conversation private to another principal than the speaker's, unless it is the host's own turn, as `context.turns.run` does; a plugin calling `runtime.runTurn` directly gets `{ ok: false, error }`. At every turn it reads whom the conversation serves: as the turn names it, else as the conversation registry records it then, else as its open session was made for or its history records, so a conversation nothing names carries on as it was after a restart, and so a conversation adopted as someone's private one while its session is open refuses everyone else's next turn, and that turn's prompts are scoped by it too.
81
+ - `IDENTITY` holds someone served and refused by turns, such as by the roles of two Discord servers, at no tier for their background turns for five minutes, instead of writing them as seen at every message.
82
+ - `IDENTITY` does not admit or claim, as someone new, an identity the configuration lists under an owner while it is unlinked, such as by another process; it is no one until a start links it again. An identity the configuration lists under an owner that was linked to them another way, such as by the CLI or a 0.8 claim, becomes the configuration's at the next start, so removing it from the configuration unlinks it.
83
+ - Discord approval and question cards follow the turn's `PromptScope`. In a shared conversation every owner with a Discord identity may answer them besides the primary owner, so a second owner approves their own owner-tier call, and a card above the speaker's tier mentions every such owner in a thread; in a private conversation only its person may, and a call above their tier expires at once without a card. A principal answers their own card from any of their Discord identities (that the identity is still theirs, and their tier, are checked again when they press, so one moved or unlinked while the card is open answers it no longer and is not resolved); the card mentions the identity that spoke, or all of theirs when the speaker is none of them, and goes to the owners when they have none. An owner-tier call is the speaker's own only when their principal is an owner, so a turn whose tier defaulted to owner asks the owners, as in 0.8. A single owner's cards read and answer exactly as in 0.8. Agent channels approve a held call by message from the principal whose turn held it on any identity of theirs, or an owner. `ChatSurface.prompts` now receives a `PromptScope` instead of a `Speaker`, so a surface of your own reads `scope.speakerId` and `scope.tier` where it read `speaker.id` and `speaker.tier`.
84
+
85
+ - Every owner uses the owner's slash commands, their autocomplete, the stop button, the agent panel, and the connector form, not only the primary owner; a member and a stranger get the same refusal as before, and a single owner's commands answer exactly as in 0.8. `ownerCommandModule`, `agentPanel`, and the stop button ask `CommandGuard.allows`; `CommandGuard.isOwner`, the primary owner by user id, is deprecated until 1.0, so a component handler of yours that still asks it refuses the other owners. A `CommandGuard` of your own needs `allows`.
86
+ - The Discord administration tools check what the primary owner may do on the server, whichever owner's turn calls them, and a private thread they create adds the primary owner, as before; background report threads still add no one. `DiscordConnection.ownerChannel()` and `notifyOwner(text)` are deprecated until 1.0 and reach the primary owner alone.
87
+
88
+ - `TurnRequest.speaker` is required, and the Pi runtime refuses a turn without one: `runTurn` answers `{ ok: false, error }` with an `AgentRunError` that names `TurnRequest.speaker` and `IDENTITY.speakerFor`, before the model is asked, where 0.8 ran it as the owner's at the owner tier. A host of your own that calls `runtime.runTurn` for its owner passes the owner's speaker, from `IDENTITY.speakerFor(<owner's principal>)`. A task a session tool runs with `session.runTask` takes the tier of the turn running in its conversation and is refused when none is, where 0.8 gave it the owner's; it also keeps that conversation's memory policy, so a persona's `memory: "none"` holds for its tasks too.
89
+
90
+ - A background turn is someone's: `BackgroundTurn.author` is `{ principalId, id, name }` and `tier` is required. `ConversationPort.background` checks the author as the turn starts and hands the claim `turn.speaker`, the principal at the lower of the turn's tier and theirs now; it skips the turn, with the reason, when the principal does not exist, is disabled, holds no tier, or holds one only from what a surface reports and was last seen more than `access.backgroundStaleDays` ago, and when the turn names no principal or tier (0.8's shape), where 0.8 ran a turn without a tier as the owner's. A claim of your own runs a background turn as `turn.speaker`. A turn naming `SYSTEM_PRINCIPAL` runs only when the host started it: a logged error's report and an outside Discord webhook's post in an agent's channel are the host's own turns at the owner tier, as in 0.8; since their text is no one's, they set up, change, and cancel no schedules, an owner's included, where 0.8 let them rewrite any, and delegate no tasks, though they still list schedules.
91
+ - Schedules and delegated tasks are their creator's principal's: a schedule stores its creator's principal as `createdById` (the same id for a person carried over from 0.8, and 0.8's `remote-mcp` runs as the primary owner) and runs as them, and a delegated task's report runs as the person whose turn delegated it; in a turn nobody is named for, the schedule and delegation tools refuse instead of acting as the owner. `ScheduleToolContext.author` (`pi-roundtable/kit`) and `DelegationJob.author` are `{ principalId, id, name, tier }`, all required.
92
+ - The router drops a message whose author, once recorded as the claim took it, is not who they were when the claim admitted it: linked to another principal meanwhile, refused now, or not recorded because the identity service failed, where 0.8.x ran it as who they were, at a first contact as a principal that might never exist. A turn admission's new optional `dropped()` is called instead of `run`, so a claim frees what it holds for the message, and a background admission's `unanswered` hears it skipped.
93
+ - The schedule and delegation tools are offered in a session only where their runs can happen: when the claim that owns the session's conversation answers background turns, or, for a conversation no chat surface carries, when it is recorded private to someone a direct channel knows (whose claim must answer background turns when a tool runs). A no-surface conversation recorded shared, or not recorded, has neither tool, whoever speaks in it: a session outlives the turn it is built in, so a speaker never decides what it offers.
94
+ A plugin calling the runtime directly must record its no-surface conversation as private before the session is built; one no direct channel knows, or a failed lookup, offers no tools.
95
+ Creation rechecks the speaker's channel and its background claim, so a lost link or claim refuses creation instead of saving work that cannot run.
96
+ Offering the tools, and `notify` in a private conversation, asks only whether a direct channel knows the person, through the new optional `DirectChannelProvider.knows(principalId)` (the Discord provider answers from the person's Discord identities) and `DirectChannels.known(principalId)`, without the network; the channel is reached, and its claim checked, when a tool runs. A Discord outage while a session is built no longer leaves it without these tools until it is rebuilt.
97
+ A conversation whose claim takes no background turns, where 0.8 offered the tools but every schedule was skipped, no longer has them.
98
+ The agent server's channels and an app's conversations that answer background turns, such as the owner's on Discord, keep every tool and its text.
99
+ - In a private conversation, as the host records it, `schedule_list`, `schedule_update`, and `schedule_cancel` see only the speaker's own schedules of the channel, so another person's outside agent bound to it lists nothing of the owner's; a shared conversation, an agent's, and one the host does not record list every schedule of the channel, as before.
100
+
101
+ ### Fixed
102
+
103
+ - A schedule's precheck, named or a script, runs only for a creator who may run the schedule's turn now, checked as the turn is: one disabled, holding no tier, or unseen past `access.backgroundStaleDays` runs none, and the run is recorded `skipped:` with the reason, with no note posted. A precheck runs at the tier the turn would run at, the schedule's capped at what its creator holds now, given as the new `PrecheckContext.tier`, so a script set at the owner tier, as 0.8 stored every schedule without one, reaches only what its creator's tier reaches. `ConversationPort.runsAs(turn)` tells who a background turn would run as without running it (`BackgroundRunsAs`: `{ speaker }`, or `{ skipped }` with the reason), and `BackgroundTurns.runsAs(schedule)` who a schedule's would; a stand-in for either adds the method. A `PrecheckScriptRunner` grants from `context.tier`, not `schedule.createdTier`.
104
+ - In a conversation no chat surface carries, such as a remote MCP session, schedules and delegated reports went to the primary owner's Discord direct messages whoever set them up, so another owner's or an admin's ran in the primary owner's conversation and were posted there. They now go to the creator principal's own direct channel, and are refused at creation when no provider reaches them or its claim takes no background turns. A conversation like that which the host has no record of is the speaker's own: its schedule tools list, read, change, and cancel only the speaker's schedules, no longer every schedule of the owner's messages.
105
+ - `callScheduleTool` recorded a schedule set up by an author without a tier, such as a plugin's tool calling it for a guest, at the owner tier, so its runs had the owner's tools; the author's tier is now required and recorded as given.
106
+ - A session tool in an agent's seat in a group round read the turn of the agent's own channel, not the seat's: `session.speaker()` was undefined there (or another turn's speaker), so a schedule made in a group round was set by the owner at the owner tier, and a `session.runTask` beside it ran at the owner tier. Both now follow the seat's turn; the task's worker still works in the agent's own conversation, under its gate and with its attachments, so a schedule it sets is posted where the agent is.
107
+ - A question card in a background report turn, such as the ops reporter's or a Discord webhook report's, mentioned the reporter's id in a thread instead of the owner, as 0.8 did, so the owner was not told of it; it now goes to the owners, as such a turn's approval cards do.
108
+ - A host restart no longer drops a held action's approval: restored 0.8 behaviour. In 0.9 `recentTranscript` built nothing, so on the first message after a restart `pendingConfirmation` was empty, the host made no confirmation judgment, the turn ran unconfirmed, and the held row was deleted. `recentTranscript` and `heldActions` now restore a conversation's held actions from the store into what `pendingConfirmation` reads, expired ones as none, without building a session, and `heldActions` no longer builds the conversation's gate in the agent flavour (shell and file calls judged against the shared workspace), which it cached for an owner or private conversation's key; the gate takes its flavour from the first turn, as before. `AgentRuntime.pendingConfirmation` is documented as memory plus what these two restored, so a host asks one of them first.
109
+
8
110
  ## [0.8.0] - 2026-10-07
9
111
 
10
112
  A Discord project from 0.7 upgrades without changing its configuration; [Migrating to 0.8](docs/migrating-0.8.md) covers what to check and how to start a host without Discord.
package/README.md CHANGED
@@ -66,15 +66,17 @@ A web chat project asks for `OWNER_NAME`, `DATABASE_URL`, and `MODEL`, and for t
66
66
  2. `.env` has a value for every variable `.env.example` lists.
67
67
  3. `roundtable.config.ts` against its schema, naming the failing key.
68
68
  4. Every plugin loads, and no two share a name.
69
- 5. Whether a plugin fills the `images` slot.
69
+ 5. Whom `access` serves: the owners, and a warning when `everyone: true` admits everyone on every surface of a host with more than one.
70
+ 6. Whether a plugin fills the `images` slot.
70
71
  The check passes with or without one; agents without an image provider get avatars generated from their display names.
71
- 6. PostgreSQL is reachable and migratable.
72
- 7. With Discord configured: the Discord token is valid, the bot is in your server, the Message Content intent is on, and the bot has the permissions it needs in the entry channel (including Pin Messages).
72
+ 7. PostgreSQL is reachable and migratable.
73
+ 8. The principals: a dry run of what the next start makes from the people the database already knows, printed as the start logs it; a configured owner's identity linked to someone else, which stops the start; and a disabled owner.
74
+ 9. With Discord configured: the Discord token is valid, the bot is in your server, the Message Content intent is on, and the bot has the permissions it needs in the entry channel (including Pin Messages).
73
75
  When the bot is not in the server, the fix is an invitation link that asks for exactly those permissions.
74
- 8. The model login exists.
75
- 9. With Discord configured: `PUBLIC_URL` is a well-formed address; with `--reachable` it also has to answer, which is only true while the bot runs.
76
+ 10. The model login exists.
77
+ 11. With Discord configured: `PUBLIC_URL` is a well-formed address; with `--reachable` it also has to answer, which is only true while the bot runs.
76
78
 
77
- It exits non-zero on any failure and changes nothing it checked.
79
+ It exits non-zero on any failure, not on a warning, and changes nothing it checked.
78
80
  A fresh project fails only on the credentials you have not entered yet, and says which.
79
81
 
80
82
  ### `start`
@@ -140,7 +142,7 @@ A host whose configuration has no `discord` and lists `webChat({ ... })` in `plu
140
142
  npx pi-roundtable init my-desk --adapter web
141
143
  ```
142
144
 
143
- Its README describes the protocol, the access map, the limits, and the [provider settings](packages/webchat/README.md#provider-settings) that keep one person one identity, such as a stable subject claim and a pinned tenant.
145
+ Its README describes the protocol, core access rules, the private notice inbox, the limits, and the [provider settings](packages/webchat/README.md#provider-settings) that keep one person one identity, such as a stable subject claim and a pinned tenant.
144
146
 
145
147
  ## MCP connectors
146
148
 
@@ -174,21 +176,41 @@ export default {
174
176
  } satisfies RoundtableConfig;
175
177
  ```
176
178
 
179
+ When someone addresses the assistant in an agent's channel, the turn also reads the channel messages posted since the assistant's last post there (channel context; see the [plugin guide](docs/plugins.md#channel-context-what-was-said-around-an-addressed-message)).
180
+ Tune it under `discord.channelContext`, or set `channelContext: false` to turn it off:
181
+
182
+ | `discord.channelContext` | Default | What it is |
183
+ | --- | --- | --- |
184
+ | `fetch` | `50` | Messages fetched before the addressed one, at most 100 |
185
+ | `keep` | `15` | Messages kept, the newest |
186
+ | `similarity` | `0.8` | How alike two consecutive messages of one author must be to count as one |
187
+ | `messageChars` | `500` | Where a message is cut |
188
+ | `botMessageChars` | `80` | Where another bot's message is cut |
189
+
190
+ When a conversation is started over in a server channel or thread, the assistant posts a short divider there so the next turn does not read what came before; direct messages get none.
191
+
192
+ | `discord.freshMarker` | Default | What it is |
193
+ | --- | --- | --- |
194
+ | text | `─── new conversation ───` | The divider's text |
195
+ | `false` | | Post no divider |
196
+
177
197
  ## Threat model
178
198
 
179
199
  pi-roundtable runs one assistant for one owner.
180
- It does not yet isolate the people it talks to from each other the way a multi-user service must; whoever lets others in takes that on.
200
+ 0.9 adds principal-scoped memory and private conversations, but this is not a complete multi-user service: credentials and host execution remain shared, and whoever lets others in takes that on.
181
201
 
182
- - **Who is trusted.** The operator controls the host, its configuration, and its credentials. The owner (`owner.id`) may use every tool. Everyone else is admitted at a tier, `member` or `admin`: in Discord from user and role ids in `speakers`, on the web from the access map over the token's roles. No token claim makes anyone the owner.
202
+ - **Who is trusted.** The operator controls the host, its configuration, and its credentials. Principals holding owner may use every tool; owner is granted only in `access.owners` or through `roundtable principal`, never a token claim. Others are admitted at `member` or `admin` through core `access` rules over verified identities and surface-prefixed roles. Linking identities makes the accounts one principal; surfaces cannot assert a speaker's authority.
183
203
  - **What a turn may do.** A turn gets only the tools its speaker's tier holds, and a tool no plugin gives a tier is the owner's alone. Tools run in the host process, with its files and its network: `web_search` and `fetch_content` can reach internal addresses, so name the tools of a web persona in its `selection`. Plugins run in the same process, with the same access.
184
- - **Approvals.** A held call is approved only by the speaker whose turn held it, at a tier that still holds the call, or by the owner, on its card or by a confirming message.
204
+ - **Approvals.** A held call is approved only by the speaker whose turn held it, at a tier that still holds the call, or, in a shared conversation, by an owner, on its card or by a confirming message. In a private conversation nobody else may, and a call above the speaker's tier is not approved there.
185
205
  - **Conversations.** In Discord an agent's channel and a group room are shared: everyone who writes there adds to one conversation the agent reads. A web chat conversation belongs to the person who opened it: in the web chat only they may read it, write in it, or answer its prompts, but the owner can read it through the owner console ([pi-roundtable-web](packages/web)), and the operator through the database and the data directory.
186
- - **Memory.** Each speaker's memory is their own. One person who writes from Discord and from the web has two identities, and two memories, until principals arrive in 0.9.
206
+ - **Memory isolation.** Memory follows the principal, not owner tier: a private conversation's person, or the current speaker in a shared room. Link accounts to share one person's memory across Discord and web. In shared requests and summaries, memory-tool and memory-loaded-worker exchanges (arguments and results together) are hidden from other speakers, and earlier reasoning is removed with provider-aware handling. Other tools' arguments and public assistant replies remain public, even if the model repeats private memory there; this is not general secret-flow prevention. Third-party compactors must use `privateCompaction` before summarizing shared history. `claude-bridge` shared turns refuse when raw reader records, including compacted turns, show another reader's private memory (even prompt-only memory and its reasoning), or an exchange is hidden from the current reader. A turn with no private memory does not block other readers, and disabling future memory does not bypass prior private history; admitting other people only warns at boot and in doctor. SYSTEM counts as primary owner for this core guard alone, so it can read and publicly repeat the owner's retained exchanges/reasoning; pre-0.9 untagged built-in memory results before the first scope record retain owner attribution. Sandbox party-wide prompt blocks must explicitly use `memory.visibility: "shared"`, and private turns require worker capabilities. Bridge replays unfiltered history outside these projections; see the [exact rule and remaining risk](docs/migration-0.9.md#claude-bridge-guard).
187
207
  - **Model credentials.** Every turn, whoever speaks, runs on the host's model login and is billed to it. Per-person credentials are planned for a later release.
188
208
  - **Data.** Conversations, memory, and schedules sit unencrypted in PostgreSQL and the data directory.
189
209
 
190
210
  ## Upgrading
191
211
 
212
+ [Migrating to 0.9](docs/migration-0.9.md) covers principal backfill, explicit speakers, package changes, the memory isolation boundary, prompt differences, and rollback.
213
+ Ordinary single-owner Discord hosts can keep their old configuration; custom runtime callers and web integrations have explicit migration steps.
192
214
  [Migrating to 0.8](docs/migrating-0.8.md) covers upgrading a Discord project from 0.7, which needs no configuration change, and starting a host without Discord.
193
215
 
194
216
  ## Commands
@@ -200,6 +222,8 @@ It does not yet isolate the people it talks to from each other the way a multi-u
200
222
  | `roundtable start` | Runs the checks that need no network, then the bot |
201
223
  | `roundtable add plugin <name>` | Adds `plugins/<name>.ts` and its test, and lists it in the config |
202
224
  | `roundtable add package <spec>` | Installs a Pi package with `bun add` and adds a plugin that loads it and gives its tools to every agent turn |
225
+ | `roundtable upgrade [--write [--unchecked]]` | Shows `roundtable.config.ts` rewritten in the 0.9 form (`owner` and `speakers` as `access`, `discord` as an adapter) as a diff; `--write` writes it after checking it serves the same people, which needs the environment the host runs with, and refuses when it cannot check; `--unchecked` writes it unchecked, with a warning |
226
+ | `roundtable principal list\|show\|create\|link\|unlink\|grant\|revoke\|disable\|enable` | Lists and changes the principals, their identities, and their roles in the database; a running host sees a change within 30 seconds |
203
227
 
204
228
  ## Changes and license
205
229
 
package/README.zh-TW.md CHANGED
@@ -63,14 +63,16 @@ Bun 會自己讀 `.env`,`.gitignore` 也已經擋掉它。
63
63
  2. `.env` 對 `.env.example` 列出的每個變數都有值。
64
64
  3. `roundtable.config.ts` 符合其 schema,失敗時指出是哪個鍵。
65
65
  4. 每個外掛都能載入,且沒有兩個外掛同名。
66
- 5. 有沒有外掛填了 `images` 槽位。沒有也不算失敗,智慧體會用顯示名稱產生頭像。
67
- 6. PostgreSQL 連得上,且能執行 migration。
68
- 7. 有設定 Discord 時:Discord token 有效、bot 已在你的伺服器裡、Message Content intent 已開啟,而且 bot 在入口頻道有它需要的權限(包含 Pin Messages)。
66
+ 5. `access` 服務哪些人:列出 owner;主機有不只一個 surface、又寫了 `everyone: true` 時提出警告,因為它會讓每個 surface 上的所有人都能對話。
67
+ 6. 有沒有外掛填了 `images` 槽位。沒有也不算失敗,智慧體會用顯示名稱產生頭像。
68
+ 7. PostgreSQL 連得上,且能執行 migration。
69
+ 8. principal:預演下次啟動會從資料庫已知的人建立哪些 principal,印出的文字和啟動時的 log 相同;設定裡 owner 的身分若連到別的 principal 就判定失敗,因為主機會因此無法啟動;owner 被停用時提出警告。
70
+ 9. 有設定 Discord 時:Discord token 有效、bot 已在你的伺服器裡、Message Content intent 已開啟,而且 bot 在入口頻道有它需要的權限(包含 Pin Messages)。
69
71
  bot 還不在伺服器裡的話,它會給你一個邀請連結,連結已經帶好這些權限。
70
- 8. 模型登入存在。
71
- 9. 有設定 Discord 時:`PUBLIC_URL` 的格式正確;加上 `--reachable` 還要求它有回應,所以 bot 得先跑起來。
72
+ 10. 模型登入存在。
73
+ 11. 有設定 Discord 時:`PUBLIC_URL` 的格式正確;加上 `--reachable` 還要求它有回應,所以 bot 得先跑起來。
72
74
 
73
- 只要有一項失敗,就以非零狀態結束,檢查過程不會改動任何東西。
75
+ 只要有一項失敗就以非零狀態結束,警告不算失敗;檢查過程不會改動任何東西。
74
76
  全新的專案只會卡在還沒填的憑證,它會列出是哪幾個。
75
77
 
76
78
  ### `start`
@@ -169,21 +171,34 @@ export default {
169
171
  } satisfies RoundtableConfig;
170
172
  ```
171
173
 
174
+ 有人在 agent 的頻道對助理說話時,那一輪也會讀進助理上次在該頻道發言之後的頻道訊息(頻道脈絡;見[外掛指南](docs/plugins.md#channel-context-what-was-said-around-an-addressed-message))。
175
+ 用 `discord.channelContext` 調整,或設 `channelContext: false` 關閉:
176
+
177
+ | `discord.channelContext` | 預設 | 說明 |
178
+ | --- | --- | --- |
179
+ | `fetch` | `50` | 在被呼叫的訊息之前抓取的訊息數,最多 100 |
180
+ | `keep` | `15` | 保留的訊息數,取最新的 |
181
+ | `similarity` | `0.8` | 同一作者相鄰兩則訊息相似到什麼程度算同一則 |
182
+ | `messageChars` | `500` | 訊息截斷的長度 |
183
+ | `botMessageChars` | `80` | 其他 bot 訊息截斷的長度 |
184
+
172
185
  ## 威脅模型
173
186
 
174
187
  pi-roundtable 為一位擁有者執行一個助理。
175
- 它還沒有像多使用者服務那樣把對話的人彼此隔開;開放給別人使用的人要自己承擔這部分。
188
+ 0.9 加入以 principal 為範圍的記憶和私人對話隔離,但模型憑證與主機執行權限仍然共用,還不是完整的多使用者服務;開放給別人使用的風險仍由營運者承擔。
176
189
 
177
- - **信任誰。** 營運者掌控主機、設定和憑證。擁有者(`owner.id`)可以使用每一個工具。其他人以某個 tier(`member` 或 `admin`)進來:Discord 依 `speakers` 裡的使用者與角色 id,網頁依存取對照表和 token 的角色。沒有任何 token claim 能讓人成為擁有者。
190
+ - **信任誰。** 營運者掌控主機、設定和憑證。principal 代表主機服務的人,identity 是連到這個人的帳號或憑證。擁有者只能由 `access.owners` 或 `roundtable principal` 授予,不能來自 token claim。其他人的 `member` 或 `admin` tier 由核心 `access` 依驗證過的身分與帶 surface 前綴的角色判斷;外掛不能自行指定發言者的權限。
178
191
  - **一輪能做什麼。** 一輪只拿得到發言者的 tier 允許的工具;沒有外掛給 tier 的工具只有擁有者能用。工具在主機的行程裡執行,用的是主機的檔案和網路:`web_search` 和 `fetch_content` 連得到內部位址,所以網頁人格要在 `selection` 裡寫明它的工具。外掛也在同一個行程裡執行,權限一樣。
179
- - **核准。** 暫緩的呼叫只能由讓它暫緩的那一輪的發言者核准,而且發言者的 tier 仍要足以執行這個呼叫;擁有者也可以核准。核准卡和確認訊息都適用這條規則。
192
+ - **核准。** 暫緩的呼叫由當事 principal 的任一已連結身分核准,而且當下 tier 仍要足夠。共用對話可以升級給擁有者核准,私人對話則只有本人能答;高於本人 tier 的呼叫會直接過期,不顯示卡片。核准卡和確認訊息都適用這條規則。
180
193
  - **對話。** Discord 裡智慧體的頻道和群組房間是共用的:在那裡發言的每個人,都會寫進智慧體讀取的同一段對話。網頁聊天的對話屬於開啟它的人:在網頁聊天裡只有開啟者本人能讀、能發言、能回答它的提問,但擁有者可以透過擁有者主控台([pi-roundtable-web](packages/web))讀取,營運者也能從資料庫和資料目錄讀到。
181
- - **記憶。** 每位發言者的記憶各自分開。同一個人從 Discord 和網頁發言會有兩個身分、兩份記憶,要等 0.9 加入 principal 才會合併。
194
+ - **記憶隔離。** 記憶依 principal 分開,不依擁有者 tier 判斷。私人對話讀本人的,共用對話讀當輪發言者的;連結 Discord 與網頁身分後,同一個人共用一份記憶。共用對話送給模型的請求和摘要會成對隱藏其他人的記憶工具與載入記憶的 worker 呼叫參數、結果,並依 provider 清除先前回合的 reasoning。其他工具參數和公開回覆仍是公開內容,即使模型在裡面重述私人記憶,也不在這項隔離保證內。第三方摘要器必須先使用 `privateCompaction`。記憶開啟時,若多位 principal 可在共用對話發言,`claude-bridge` 會在開機與每輪送出前被拒絕,因為它重播未過濾的歷史;請改用其他 provider、設 `memory: false`,或限制只有一位 principal。
182
195
  - **模型憑證。** 每一輪不論誰發言,都用主機的模型登入執行、由它計費。每個人自己的憑證預計在之後的版本加入。
183
196
  - **資料。** 對話、記憶和排程以未加密的形式存在 PostgreSQL 和資料目錄裡。
184
197
 
185
198
  ## 升級
186
199
 
200
+ [升級到 0.9](docs/migration-0.9.md)(英文)說明 principal 回填、明確的發言者、套件遷移、記憶隔離範圍、prompt 差異和回退方式。
201
+ 一般單一擁有者的 Discord 主機可以保留舊設定;直接呼叫 runtime 的外掛和網頁整合則有必要的遷移步驟。
187
202
  [升級到 0.8](docs/migrating-0.8.md)(英文)說明怎麼把 0.7 的 Discord 專案升級(不需要改設定),以及怎麼啟動沒有 Discord 的主機。
188
203
 
189
204
  ## 指令
@@ -195,6 +210,8 @@ pi-roundtable 為一位擁有者執行一個助理。
195
210
  | `roundtable start` | 先執行不需要網路的檢查,再啟動 bot |
196
211
  | `roundtable add plugin <name>` | 新增 `plugins/<name>.ts` 和它的測試,並列進設定 |
197
212
  | `roundtable add package <spec>` | 用 `bun add` 安裝 Pi package,並新增一個載入它、把它的工具交給每一輪 agent 的外掛 |
213
+ | `roundtable upgrade [--write]` | 以 diff 顯示改寫成 0.9 寫法的 `roundtable.config.ts`(`owner` 與 `speakers` 改成 `access`,`discord` 改成 adapter);加上 `--write` 時,先確認改寫後服務的人不變,再寫入 |
214
+ | `roundtable principal list\|show\|create\|link\|unlink\|grant\|revoke\|disable\|enable` | 列出並修改資料庫裡的 principal、它們的身分與角色;執行中的主機在 30 秒內看到變更 |
198
215
 
199
216
  ## 變更與授權
200
217