@agimon-ai/po-profile 0.0.0-alpha.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.
@@ -0,0 +1,203 @@
1
+ # Po profiles authoring contract
2
+
3
+ ## Sources and shape
4
+
5
+ Po reads profile declarations in this order:
6
+
7
+ 1. `~/.po/profiles.yaml`
8
+ 2. `<repoRoot>/.po/profiles.yaml`
9
+
10
+ The current catalog shape is:
11
+
12
+ ```yaml
13
+ profiles:
14
+ roots: [agents/acme]
15
+ entries:
16
+ writer:
17
+ persona: agents/special/writer
18
+ env:
19
+ BRAND: acme
20
+ TONE: concise
21
+ defaultProfile: writer
22
+ ```
23
+
24
+ The document may contain only the `profiles` mapping. In catalog form, `profiles` may contain only
25
+ `roots`, `entries` and `defaultProfile`. Po still accepts the earlier flat form, where profile
26
+ names appear directly below `profiles`, but new configuration should use catalog form.
27
+
28
+ `defaultProfile` names the persona a run starts on when nothing else names one, the same way
29
+ `modes.yaml` declares `defaultMajorMode` and `domains.yaml` declares `defaultDomains`. A repository
30
+ declaration replaces a personal one. `po --profile <name>` overrides it for one run, and an
31
+ inherited `PO_PROFILE` wins so a nested run stays on the persona its parent chose. A
32
+ `defaultProfile` naming a profile that does not exist fails the launch rather than starting with no
33
+ persona.
34
+
35
+ Every relative root or persona path resolves against the file that declares it. Repository paths
36
+ resolve from the repository root. Personal paths resolve from `~/.po`.
37
+
38
+ ## Root discovery
39
+
40
+ Each `profiles.roots` value must resolve to an existing directory.
41
+
42
+ - If the root directly contains `profile.md`, `SOUL.md`, or `AGENTS.md`, the root is one profile.
43
+ Its directory name is the profile name, and its children are not scanned.
44
+ - Otherwise, Po inspects only the root's direct-child directories. A child becomes a profile
45
+ when it directly contains at least one persona file.
46
+ - Discovery does not recurse. Add another root or an explicit entry for a deeper persona.
47
+ - A discovered profile has an empty environment mapping.
48
+ - Unreadable candidates and candidates whose real path or persona files escape the configured root
49
+ are ignored. An invalid configured root itself is an error.
50
+
51
+ ## Explicit entries and path confinement
52
+
53
+ An explicit entry may contain only `persona`, `env`, and optional `integration`. `persona` is required, must be a non-empty
54
+ relative path, and must resolve to a directory containing readable persona content.
55
+
56
+ The persona must remain inside either:
57
+
58
+ - the `agents/` directory beside the declaring repository root or personal `.po` directory, or
59
+ - one of the `profiles.roots` directories declared in that same file.
60
+
61
+ Po checks both the lexical path and the real filesystem path. A persona-directory symlink cannot
62
+ escape an allowed root. Each resolved persona file must also remain inside the real persona
63
+ directory, so a file symlink cannot import instructions from outside it. Invalid explicit entries
64
+ fail loading instead of being skipped.
65
+
66
+ Persona content is assembled from non-empty files in this order:
67
+
68
+ 1. `profile.md`
69
+ 2. `SOUL.md`
70
+ 3. `AGENTS.md`
71
+
72
+ ## Nonsecret integration bindings
73
+
74
+ An explicit profile may bind one integration provider. The binding contains identifiers only,
75
+ never API keys, passwords, cookies, or resolved credential values:
76
+
77
+ ```yaml
78
+ profiles:
79
+ entries:
80
+ worker:
81
+ persona: agents/worker
82
+ integration:
83
+ provider: unsendbox
84
+ organizationId: org-id
85
+ mailboxId: mailbox-id
86
+ credentialRef: local-auth-reference
87
+ secrets:
88
+ PASSWORD:
89
+ credentialId: existing-credential-id
90
+ field: password
91
+ allowedOrigins: [https://login.example.com]
92
+ browser:
93
+ profileName: worker
94
+ allowedOrigins: [https://login.example.com]
95
+ ```
96
+
97
+ `provider`, `organizationId`, `mailboxId`, `credentialRef`, and `secrets` are required.
98
+ `credentialRef` identifies authentication supplied by the concrete integration outside profile
99
+ configuration. An empty `secrets` mapping is allowed. Each uppercase secret alias names an existing
100
+ credential and field; `${secrets.PASSWORD}` is consumed by the bound browser, not profile `env`.
101
+ Each secret requires nonempty exact HTTP(S) `allowedOrigins`, without paths, wildcards, userinfo, or
102
+ trailing slashes. The optional browser binding requires a safe profile name, not a filesystem path,
103
+ and its own nonempty `allowedOrigins` containing every secret origin. Unknown metadata is rejected.
104
+
105
+ The public `@agimon-ai/po-profile` API exposes the session-owned `po/profile-runtime` Cordis service.
106
+ Consumers declare that service as an injected dependency, use `requireProfileRuntime(context)`,
107
+ and attach exactly one concrete provider with `attachProvider(providerName, factory)`. The factory
108
+ receives deeply immutable binding metadata plus `{profileName, generation, signal}` and returns
109
+ `{resolveSecret(reference, signal), dispose()}`. The attachment returns an awaited cleanup operation.
110
+ Do not invoke `runtime.resolveSecret` from the factory: resolution is rejected until initialization
111
+ finishes. The runtime does not load provider SDKs or persist resolved values.
112
+
113
+ `runtime.resolveSecret(name, {origin, signal})` accepts a trusted actual target-frame origin from
114
+ the browser boundary, never a tool-supplied origin. `ready()` rejects if a configured provider is
115
+ missing or failed. Session startup and new-turn admission both require readiness. Profile changes
116
+ and shutdown abort old generations before asynchronous provider cleanup; late secret results are
117
+ rejected rather than delivered to a replacement profile. Persona-only profiles remain compatible.
118
+ The concrete browser integration owns persistence and exclusive profile leases.
119
+
120
+ ## Persona identity front-matter
121
+
122
+ A persona may open `profile.md` with a YAML front-matter block declaring who it is and how it
123
+ sounds. Only `profile.md` is read this way; `SOUL.md` and `AGENTS.md` are always prose.
124
+
125
+ ```markdown
126
+ ---
127
+ name: Rhea
128
+ icon: avatar.png
129
+ voice:
130
+ voice: Karen
131
+ rate: 175
132
+ ---
133
+
134
+ You are Rhea, a blunt systems architect.
135
+ ```
136
+
137
+ All three fields are optional. `name` replaces the profile's directory name in the cockpit,
138
+ `icon` is an image beside `profile.md`, and `voice` shadows `voice.autoCapture.tts` from
139
+ `config.yaml` per field: a persona setting only `rate` keeps the configured voice. The TTS `engine`
140
+ is never overridable, because it selects the synthesiser rather than the persona.
141
+
142
+ Because identity lives here rather than in `profiles.yaml`, it applies to profiles found by root
143
+ discovery as well as to explicit entries.
144
+
145
+ A block is only treated as identity when it is fenced on the very first line, closed, parses as a
146
+ YAML mapping, and declares at least one of `name`, `icon`, or `voice`. Anything else is left in the
147
+ persona text byte for byte, so a persona that legitimately opens with a horizontal rule is
148
+ unaffected. A recognised block is removed from the prompt: it describes the persona rather than
149
+ instructing it. A `profile.md` holding nothing but front-matter therefore has no persona text, and
150
+ the profile fails to load unless `SOUL.md` or `AGENTS.md` supplies it.
151
+
152
+ The icon is read once when the profile loads and travels to the cockpit as a `data:` URL, so no
153
+ file is served at request time. It must be a PNG, JPEG, GIF, or WebP, checked by content rather
154
+ than by file extension. SVG is refused because an SVG rendered in the cockpit executes script. The
155
+ icon must stay inside the persona directory under the same real-path rule as persona files, so a
156
+ symlink cannot smuggle a file out of it. An icon that is missing, unreadable, of another type, or
157
+ larger than 256 KiB once encoded is dropped: the persona keeps its name and the cockpit falls back
158
+ to the default avatar rather than failing the profile.
159
+
160
+ ## Precedence
161
+
162
+ Po resolves names in four passes, from lowest to highest precedence:
163
+
164
+ 1. Personal root discoveries
165
+ 2. Repository root discoveries
166
+ 3. Personal explicit entries
167
+ 4. Repository explicit entries
168
+
169
+ A later profile replaces an earlier profile with the same name. This means any explicit entry
170
+ overrides discovery, including a personal explicit entry replacing a repository discovery. A
171
+ repository explicit entry wins over a personal explicit entry. Unrelated names from all sources
172
+ remain available, and the final list is sorted by profile name.
173
+
174
+ ## Environment defaults and activation
175
+
176
+ Every `env` key maps to a nonsecret string. Secret alias expressions are rejected in `env`. Applying a profile writes only variables that are not already
177
+ exported by the caller. When switching profiles, Po removes a value contributed by the previous
178
+ profile only if that value has not since changed, then applies the next profile's defaults.
179
+
180
+ The `/profile` command resolves the named profile and executes the change through the session
181
+ transition coordinator. A profile change uses Pi reload so extensions re-read the environment.
182
+ Po assembles the persona into a private run-directory file, and the persona Pi entry appends
183
+ that content to the system prompt after reload.
184
+
185
+ Autonomous voice can switch profiles too. The `profile` voice capability lists the configured
186
+ personas and queues a switch, which arrives as `/profile --voice-switch-token=…`. The chosen name
187
+ travels in a TTL-bounded handoff rather than in the command text, so an edited transcript cannot be
188
+ turned into a switch to a different persona. A spoken switch therefore takes the same reload path
189
+ as the command, which is also what lets the new persona's voice take effect: the spoken voice is
190
+ read when autonomous voice activates.
191
+
192
+ ## Verification
193
+
194
+ ```sh
195
+ pnpm nx test @agimon-ai/po-config
196
+ pnpm nx typecheck @agimon-ai/po-config
197
+ pnpm nx test @agimon-ai/po-profile
198
+ pnpm nx typecheck @agimon-ai/po-profile
199
+ pnpm --filter @agimon-ai/po exec po --profile writer --explain
200
+ ```
201
+
202
+ Use an actual parent session to smoke-test `/profile` when changing transition, environment, or
203
+ persona application behavior.