pi-profile-switch 0.1.0 → 0.2.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.
- package/README.md +18 -24
- package/README.zh-CN.md +18 -24
- package/examples/profiles.json +6 -28
- package/extensions/pi-profile-switch/index.ts +102 -14
- package/package.json +1 -1
- package/schemas/profiles.schema.json +2 -1
- package/src/profile-badge.ts +142 -0
- package/src/profile-catalog-store.ts +4 -5
- package/src/profile-catalog.ts +19 -43
- package/src/profile-presets.ts +60 -0
- package/src/profile-resolver.ts +51 -18
- package/src/runtime-state-store.ts +13 -0
- package/src/startup-selection.ts +1 -4
- package/src/switching/activate-profile.ts +8 -6
- package/src/switching/list-profiles.ts +8 -11
- package/src/switching/profile-wizard.ts +39 -6
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Named profiles for [Pi](https://github.com/badlogic/pi-mono). A profile selects what the model sees and which capabilities the session uses — skills, MCP servers, tools, a model preset, and extra instructions — switchable in the same Pi process.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
A shipped `read-only` preset covers the read-and-report workflow; other workflows you write yourself — a profile can narrow the tool set, hide skills, pin a model, or add instructions.
|
|
8
8
|
|
|
9
9
|
`pi-profile-switch` is a plain Pi package (ADR-0007). It installs like any other extension, leaves Pi's configuration directory untouched, and keeps sessions, packages, project trust, and every other installed extension native.
|
|
10
10
|
|
|
@@ -23,32 +23,36 @@ Requires [Pi](https://www.npmjs.com/package/@earendil-works/pi-coding-agent) (in
|
|
|
23
23
|
pi
|
|
24
24
|
|
|
25
25
|
# Use a profile for this run only (not saved)
|
|
26
|
-
pi --profile
|
|
26
|
+
pi --profile read-only
|
|
27
27
|
|
|
28
28
|
# The explicit native baseline
|
|
29
29
|
pi --profile default
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Create profiles with `/profile create`, which writes `~/.pi/agent/profiles.json` (global) or `<project>/.pi/profiles.json` (project, trusted projects only). The wizard offers the shipped `read-only` preset — Pi's built-in tools only, no skills, MCP servers, or model assumed — or a blank definition:
|
|
33
33
|
|
|
34
34
|
```json
|
|
35
35
|
{
|
|
36
|
-
"schemaVersion":
|
|
36
|
+
"schemaVersion": 1,
|
|
37
37
|
"profiles": {
|
|
38
|
-
"
|
|
39
|
-
"label": "
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
"read-only": {
|
|
39
|
+
"label": "Read-only",
|
|
40
|
+
"description": "Read-only session; no skills or MCP servers assumed — add your own.",
|
|
41
|
+
"tools": [
|
|
42
|
+
"read",
|
|
43
|
+
"grep",
|
|
44
|
+
"find",
|
|
45
|
+
"ls"
|
|
46
|
+
],
|
|
47
|
+
"instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
Profiles **reference** resources by name — they never copy them.
|
|
53
|
+
A preset is a one-time copy into your catalog: it is not tracked, so a package update never changes a profile you already created. Profiles **reference** resources by name — they never copy them. [`examples/profiles.json`](examples/profiles.json) is that same preset as a catalog; the field reference is [`schemas/profiles.schema.json`](schemas/profiles.schema.json).
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
`schemaVersion` is 1. A profile cannot select extensions: they load natively in every profile, so manage them with `pi install`.
|
|
52
56
|
|
|
53
57
|
## What a profile controls
|
|
54
58
|
|
|
@@ -75,6 +79,8 @@ In the TUI, the `/profile` command family manages everything in-session:
|
|
|
75
79
|
| `/profile customize` / `/profile reset` | Narrow the active profile for this session only |
|
|
76
80
|
| `/mcp enable\|disable <server>` | Toggle MCP servers in the active profile |
|
|
77
81
|
|
|
82
|
+
While a non-`default` profile is active, the footer shows `profile: <name>`, with `*` appended when a session-only overlay is in effect. `default` shows no badge, so an unprofiled session keeps Pi's native footer.
|
|
83
|
+
|
|
78
84
|
All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizards are TUI-only.
|
|
79
85
|
|
|
80
86
|
## Guarantees
|
|
@@ -84,18 +90,6 @@ All commands work in non-interactive modes (`--mode rpc|print|json`); CRUD wizar
|
|
|
84
90
|
- **Fail safe** — untrusted project directories are never read; a failed activation applies nothing and reports the cause.
|
|
85
91
|
- **No reload** — switching re-applies runtime state in place; the next turn's prompt carries the new selection.
|
|
86
92
|
|
|
87
|
-
## Migrating from the launcher
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
pi-profile review # before
|
|
91
|
-
pi --profile review # after
|
|
92
|
-
|
|
93
|
-
pi-profile review -- --mode rpc # before
|
|
94
|
-
pi --profile review --mode rpc # after
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
`/profile reload` and the `/profile resource` commands are gone: skills are read on demand and catalogs are re-read on every use, so there is nothing to reload. Remove leftover `~/.pi/agent/pi-profile/runtime/` directories; the extension no longer creates them.
|
|
98
|
-
|
|
99
93
|
## Docs
|
|
100
94
|
|
|
101
95
|
- [Architecture](docs/architecture/overview.md) · [ADRs](docs/adr/) · [Glossary](CONTEXT.md) (Chinese)
|
package/README.zh-CN.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[Pi](https://github.com/badlogic/pi-mono) 的命名 profile 扩展。一个 profile 选择模型看到什么、会话使用哪些能力——skills、MCP server、tools、预制模型和附加指令——并在同一 Pi 进程内即时切换。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
随包提供一个 `read-only` 预设覆盖「读代码 + 报告」的工作流;其余工作流由你自己定义——profile 可以收窄工具集、隐藏 skill、固定模型或附加指令。
|
|
8
8
|
|
|
9
9
|
`pi-profile-switch` 是普通 Pi package(ADR-0007):像其他扩展一样安装,不改动 Pi 的配置目录,session、packages、项目信任和其他扩展全部保持原生。
|
|
10
10
|
|
|
@@ -23,32 +23,36 @@ pi install npm:pi-profile-switch
|
|
|
23
23
|
pi
|
|
24
24
|
|
|
25
25
|
# 仅本次启动使用指定 profile(不保存)
|
|
26
|
-
pi --profile
|
|
26
|
+
pi --profile read-only
|
|
27
27
|
|
|
28
28
|
# 显式的原生基线
|
|
29
29
|
pi --profile default
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
用 `/profile create` 创建 profile,写入 `~/.pi/agent/profiles.json`(全局)或 `<项目>/.pi/profiles.json`(项目级,仅限已信任项目)。向导提供随包的 `read-only` 预设——只用 Pi 内建工具,不假设任何 skill、MCP server 或模型——也可以从空定义开始:
|
|
33
33
|
|
|
34
34
|
```json
|
|
35
35
|
{
|
|
36
|
-
"schemaVersion":
|
|
36
|
+
"schemaVersion": 1,
|
|
37
37
|
"profiles": {
|
|
38
|
-
"
|
|
39
|
-
"label": "
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
"read-only": {
|
|
39
|
+
"label": "Read-only",
|
|
40
|
+
"description": "Read-only session; no skills or MCP servers assumed — add your own.",
|
|
41
|
+
"tools": [
|
|
42
|
+
"read",
|
|
43
|
+
"grep",
|
|
44
|
+
"find",
|
|
45
|
+
"ls"
|
|
46
|
+
],
|
|
47
|
+
"instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
}
|
|
47
51
|
```
|
|
48
52
|
|
|
49
|
-
Profile
|
|
53
|
+
预设只被复制一次:它不被跟踪,包升级不会改动你已经创建的 profile。Profile 只**引用**资源,从不复制资源。[`examples/profiles.json`](examples/profiles.json) 就是同一个预设构成的 catalog;字段参考见 [`schemas/profiles.schema.json`](schemas/profiles.schema.json)。
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
`schemaVersion` 为 1。Profile 不选择 extension:extensions 在所有 profile 中原生加载,请用 `pi install` 管理。
|
|
52
56
|
|
|
53
57
|
## Profile 控制的范围
|
|
54
58
|
|
|
@@ -75,6 +79,8 @@ Profile 只**引用**资源,从不复制资源。完整示例见 [`examples/pr
|
|
|
75
79
|
| `/profile customize` / `/profile reset` | 仅本次会话收窄活动 profile |
|
|
76
80
|
| `/mcp enable\|disable <server>` | 在活动 profile 中开关 MCP server |
|
|
77
81
|
|
|
82
|
+
活动 profile 不是 `default` 时,footer 显示 `profile: <name>`;本次会话的 overlay 生效时追加 `*`。`default` 不显示 badge,未使用 profile 的会话保持 Pi 原生 footer。
|
|
83
|
+
|
|
78
84
|
非交互模式(`--mode rpc|print|json`)下命令同样生效;CRUD 向导仅 TUI 可用。
|
|
79
85
|
|
|
80
86
|
## 保证
|
|
@@ -84,18 +90,6 @@ Profile 只**引用**资源,从不复制资源。完整示例见 [`examples/pr
|
|
|
84
90
|
- **失败安全**——未信任的项目目录从不读取;激活失败时不应用任何设置并报出原因。
|
|
85
91
|
- **无 reload**——切换在原位重新应用运行时状态;下一个 turn 的 prompt 直接带上新选择。
|
|
86
92
|
|
|
87
|
-
## 从 launcher 迁移
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
pi-profile review # 旧
|
|
91
|
-
pi --profile review # 新
|
|
92
|
-
|
|
93
|
-
pi-profile review -- --mode rpc # 旧
|
|
94
|
-
pi --profile review --mode rpc # 新
|
|
95
|
-
```
|
|
96
|
-
|
|
97
|
-
`/profile reload` 与 `/profile resource` 命令已移除:skill 内容按需读取、catalog 每次使用重新读取,没有需要 reload 的缓存。删除遗留的 `~/.pi/agent/pi-profile/runtime/` 目录;新版本不再创建它们。
|
|
98
|
-
|
|
99
93
|
## 文档
|
|
100
94
|
|
|
101
95
|
- [架构设计](docs/architecture/overview.md) · [ADR](docs/adr/) · [术语表](CONTEXT.md)
|
package/examples/profiles.json
CHANGED
|
@@ -1,38 +1,16 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 1,
|
|
3
3
|
"profiles": {
|
|
4
|
-
"
|
|
5
|
-
"label": "
|
|
6
|
-
"description": "Read-
|
|
7
|
-
"skills": [
|
|
8
|
-
"code-review",
|
|
9
|
-
"diagnosing-bugs"
|
|
10
|
-
],
|
|
11
|
-
"mcp": [
|
|
12
|
-
"github"
|
|
13
|
-
],
|
|
4
|
+
"read-only": {
|
|
5
|
+
"label": "Read-only",
|
|
6
|
+
"description": "Read-only session; no skills or MCP servers assumed — add your own.",
|
|
14
7
|
"tools": [
|
|
15
8
|
"read",
|
|
16
9
|
"grep",
|
|
17
10
|
"find",
|
|
18
|
-
"
|
|
19
|
-
],
|
|
20
|
-
"instructions": "Review only; do not modify files."
|
|
21
|
-
},
|
|
22
|
-
"impl": {
|
|
23
|
-
"label": "Implementation",
|
|
24
|
-
"skills": [
|
|
25
|
-
"tdd"
|
|
26
|
-
],
|
|
27
|
-
"mcp": [
|
|
28
|
-
"github",
|
|
29
|
-
"linear"
|
|
11
|
+
"ls"
|
|
30
12
|
],
|
|
31
|
-
"
|
|
32
|
-
"provider": "deepseek",
|
|
33
|
-
"id": "deepseek-v4-pro",
|
|
34
|
-
"thinkingLevel": "high"
|
|
35
|
-
}
|
|
13
|
+
"instructions": "Read-only session: inspect and report; never create, edit, rename, or delete files.\nIf a change is needed, describe it in your reply instead of applying it.\nDo not run commands that modify state (installs, formatters, commits, pushes, network writes).\nPrefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.\nGround claims in evidence: cite file:line and separate verified facts from inferences.\nReply in English."
|
|
36
14
|
}
|
|
37
15
|
}
|
|
38
16
|
}
|
|
@@ -9,13 +9,16 @@ import {
|
|
|
9
9
|
import { discoverAdapterServerNames } from "../../src/mcp-config.ts";
|
|
10
10
|
import { probeAdapterPresence } from "../../src/mcp-coordination.ts";
|
|
11
11
|
import { readSessionChoices } from "../../src/model-selection.ts";
|
|
12
|
+
import { buildProfileBadge, PROFILE_STATUS_KEY, renderProfileBadge } from "../../src/profile-badge.ts";
|
|
12
13
|
import type { ProfileDefinition } from "../../src/profile-catalog.ts";
|
|
13
14
|
import {
|
|
14
15
|
formatSelectionWarnings,
|
|
16
|
+
formatSkillWarnings,
|
|
17
|
+
skillWarnings,
|
|
15
18
|
type LiveResources,
|
|
16
19
|
type ResolvedSelection,
|
|
17
20
|
} from "../../src/profile-resolver.ts";
|
|
18
|
-
import { RuntimeStateStore, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
|
|
21
|
+
import { RuntimeStateStore, overlayNarrows, stateDirFor, type RuntimeOverlay } from "../../src/runtime-state-store.ts";
|
|
19
22
|
import {
|
|
20
23
|
applySkillsFilter,
|
|
21
24
|
formatInstructionsBlock,
|
|
@@ -69,6 +72,8 @@ import { buildStatusReport, formatStatusMarkdown } from "../../src/switching/sta
|
|
|
69
72
|
* instructions. Unselected skills stay loaded and `/skill:`-invocable.
|
|
70
73
|
* - `/profile …` command family and `/mcp enable|disable`.
|
|
71
74
|
* - Retry pending tool literals each turn until MCP/extension tools register.
|
|
75
|
+
* - Footer badge: `profile: <name>` (plus `*` for a runtime overlay) in Pi's
|
|
76
|
+
* footer status line while a non-`default` profile is active.
|
|
72
77
|
*/
|
|
73
78
|
|
|
74
79
|
type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => BuildSystemPromptOptions };
|
|
@@ -77,6 +82,21 @@ type ContextWithOptions = ExtensionContext & { getSystemPromptOptions?: () => Bu
|
|
|
77
82
|
interface Activation {
|
|
78
83
|
selection: ResolvedSelection;
|
|
79
84
|
skillsOutcome?: SkillsFilterOutcome;
|
|
85
|
+
/** Whether the skill references were checked against Pi's loaded set.
|
|
86
|
+
* False after a startup activation: `session_start`'s event context
|
|
87
|
+
* cannot read that list, so the check moves to the first turn. */
|
|
88
|
+
skillsChecked: boolean;
|
|
89
|
+
/** The overlay this runtime was activated with, when one is in effect. */
|
|
90
|
+
overlay?: RuntimeOverlay;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Maps an activation result onto the runtime state `current` mirrors. */
|
|
94
|
+
function activationOf(result: ActivationResult, skillsChecked: boolean): Activation {
|
|
95
|
+
return {
|
|
96
|
+
selection: result.selection,
|
|
97
|
+
skillsChecked,
|
|
98
|
+
...(result.overlay === undefined ? {} : { overlay: result.overlay }),
|
|
99
|
+
};
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
/** Subcommands that mutate a catalog; they need dialog-capable UI. */
|
|
@@ -98,6 +118,33 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
98
118
|
const explicit = detectExplicitDeclarations(process.argv.slice(2));
|
|
99
119
|
let current: Activation | undefined;
|
|
100
120
|
let filterWarningShown = false;
|
|
121
|
+
/** The last badge written to the footer, so a refresh only talks to Pi
|
|
122
|
+
* when the rendering actually changed. */
|
|
123
|
+
let badgeText: string | undefined;
|
|
124
|
+
|
|
125
|
+
/** The only writer of `current`'s profile identity (`selection.name` and
|
|
126
|
+
* `overlay`) and of the footer badge. Both mirror the selection this
|
|
127
|
+
* runtime applied, so a failed activation (which throws before reaching
|
|
128
|
+
* here) never claims to be active. The per-turn updates (`pendingTools`,
|
|
129
|
+
* `skillsOutcome`) leave the identity and the badge untouched. */
|
|
130
|
+
function setCurrent(ctx: ExtensionContext, next: Activation | undefined): void {
|
|
131
|
+
current = next;
|
|
132
|
+
refreshBadge(ctx);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Re-renders the badge from `current`. `default` and an unapplied profile
|
|
136
|
+
* render no badge, which removes Pi's footer status line entirely. */
|
|
137
|
+
function refreshBadge(ctx: ExtensionContext): void {
|
|
138
|
+
if (!ctx.hasUI) return;
|
|
139
|
+
const badge =
|
|
140
|
+
current === undefined
|
|
141
|
+
? undefined
|
|
142
|
+
: buildProfileBadge(current.selection.name, { overlay: overlayNarrows(current.overlay) });
|
|
143
|
+
const text = badge === undefined ? undefined : renderProfileBadge(badge, ctx.ui.theme);
|
|
144
|
+
if (text === badgeText) return;
|
|
145
|
+
badgeText = text;
|
|
146
|
+
ctx.ui.setStatus(PROFILE_STATUS_KEY, text);
|
|
147
|
+
}
|
|
101
148
|
|
|
102
149
|
const surface = (ctx: ExtensionContext): ApplySurface => ({
|
|
103
150
|
getAllTools: () => pi.getAllTools(),
|
|
@@ -120,8 +167,10 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
120
167
|
}
|
|
121
168
|
|
|
122
169
|
async function loadLive(ctx: ExtensionContext, projectTrusted: boolean): Promise<LiveResources> {
|
|
170
|
+
// Only command contexts expose the system-prompt options; the
|
|
171
|
+
// `session_start` event context has no accessor, so the loaded skills
|
|
172
|
+
// stay unknown there and their existence check moves to the first turn.
|
|
123
173
|
const options = (ctx as ContextWithOptions).getSystemPromptOptions?.();
|
|
124
|
-
const skills = (options?.skills ?? []).map((skill) => ({ name: skill.name, filePath: skill.filePath }));
|
|
125
174
|
const adapterPresent = probeAdapterPresence(pi.events);
|
|
126
175
|
let servers: string[] = [];
|
|
127
176
|
if (adapterPresent) {
|
|
@@ -131,7 +180,13 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
131
180
|
notify(ctx, error instanceof Error ? error.message : String(error), "warning");
|
|
132
181
|
}
|
|
133
182
|
}
|
|
134
|
-
return {
|
|
183
|
+
return {
|
|
184
|
+
...(options?.skills === undefined
|
|
185
|
+
? {}
|
|
186
|
+
: { skills: options.skills.map((skill) => ({ name: skill.name, filePath: skill.filePath })) }),
|
|
187
|
+
toolNames: pi.getAllTools().map((tool) => tool.name),
|
|
188
|
+
mcp: { adapterPresent, servers },
|
|
189
|
+
};
|
|
135
190
|
}
|
|
136
191
|
|
|
137
192
|
/** Builds the dependencies for one activation. `force` marks an explicit
|
|
@@ -162,20 +217,17 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
162
217
|
overlay: options?.overlay ?? null,
|
|
163
218
|
persist: options?.persist ?? true,
|
|
164
219
|
});
|
|
165
|
-
|
|
166
|
-
reportWarnings(ctx, result.warnings);
|
|
220
|
+
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
167
221
|
reportWarnings(ctx, formatSelectionWarnings(result.selection));
|
|
168
222
|
return result;
|
|
169
223
|
}
|
|
170
224
|
|
|
171
225
|
async function profileEntries(ctx: ExtensionContext): Promise<ProfileListEntry[]> {
|
|
172
|
-
|
|
226
|
+
return listProfiles({
|
|
173
227
|
realAgentDir: getAgentDir(),
|
|
174
228
|
cwd: ctx.cwd,
|
|
175
229
|
projectTrusted: ctx.isProjectTrusted(),
|
|
176
230
|
});
|
|
177
|
-
reportWarnings(ctx, warnings);
|
|
178
|
-
return entries;
|
|
179
231
|
}
|
|
180
232
|
|
|
181
233
|
function sendListMessage(entries: ProfileListEntry[]): void {
|
|
@@ -219,7 +271,12 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
219
271
|
const wizard = await runProfileCreateWizard(ctx.ui, { projectTrusted: scopeInput.projectTrusted });
|
|
220
272
|
if (wizard === undefined) return;
|
|
221
273
|
await createProfile(scopeInput, wizard.scope, wizard.name, wizard.definition);
|
|
222
|
-
|
|
274
|
+
const origin = wizard.preset !== undefined ? ` from preset "${wizard.preset}"` : "";
|
|
275
|
+
notify(
|
|
276
|
+
ctx,
|
|
277
|
+
`created profile "${wizard.name}" (${wizard.scope})${origin} — activate with /profile use ${wizard.name}`,
|
|
278
|
+
"info",
|
|
279
|
+
);
|
|
223
280
|
return;
|
|
224
281
|
}
|
|
225
282
|
if (subcommand === "duplicate") {
|
|
@@ -307,7 +364,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
307
364
|
}
|
|
308
365
|
|
|
309
366
|
pi.on("session_start", async (_event, ctx) => {
|
|
310
|
-
|
|
367
|
+
setCurrent(ctx, undefined);
|
|
311
368
|
filterWarningShown = false;
|
|
312
369
|
const agentDir = getAgentDir();
|
|
313
370
|
const projectTrusted = ctx.isProjectTrusted();
|
|
@@ -351,6 +408,39 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
351
408
|
},
|
|
352
409
|
};
|
|
353
410
|
}
|
|
411
|
+
// A startup activation cannot see Pi's skill list, so its references are
|
|
412
|
+
// checked here, on the first turn that carries the complete set — by then
|
|
413
|
+
// `resources_discover` has contributed every extension's skills. The
|
|
414
|
+
// corrected warnings replace the (empty) startup ones, so `/profile
|
|
415
|
+
// status` reports the same thing the user was told.
|
|
416
|
+
if (!current.skillsChecked) {
|
|
417
|
+
const refs = current.selection.skills?.refs;
|
|
418
|
+
const corrected =
|
|
419
|
+
refs === undefined || refs === "all"
|
|
420
|
+
? undefined
|
|
421
|
+
: skillWarnings(
|
|
422
|
+
refs,
|
|
423
|
+
(event.systemPromptOptions.skills ?? []).map((skill) => ({
|
|
424
|
+
name: skill.name,
|
|
425
|
+
filePath: skill.filePath,
|
|
426
|
+
})),
|
|
427
|
+
);
|
|
428
|
+
current = {
|
|
429
|
+
...current,
|
|
430
|
+
skillsChecked: true,
|
|
431
|
+
...(corrected === undefined
|
|
432
|
+
? {}
|
|
433
|
+
: { selection: { ...current.selection, warnings: { ...current.selection.warnings, ...corrected } } }),
|
|
434
|
+
};
|
|
435
|
+
if (corrected !== undefined) {
|
|
436
|
+
reportWarnings(ctx, formatSkillWarnings(current.selection.name, corrected));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// Pi has no extension-visible theme-change event and footer statuses are
|
|
440
|
+
// stored as finished strings, so re-render once per turn: a `/theme`
|
|
441
|
+
// switch is picked up without waiting for the next profile change.
|
|
442
|
+
// `refreshBadge` dedupes, so an unchanged badge sends nothing.
|
|
443
|
+
refreshBadge(ctx);
|
|
354
444
|
const filtered = applySkillsFilter({
|
|
355
445
|
systemPrompt: event.systemPrompt,
|
|
356
446
|
options: event.systemPromptOptions,
|
|
@@ -418,8 +508,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
418
508
|
const deps = await activationDeps(ctx, false);
|
|
419
509
|
const target = { profile: { name: current.selection.name, source: current.selection.source } };
|
|
420
510
|
const result = await customizeOverlay({ ...deps, ...target }, parseCustomizeArgs(rest.join(" ")));
|
|
421
|
-
|
|
422
|
-
reportWarnings(ctx, result.warnings);
|
|
511
|
+
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
423
512
|
notify(ctx, `overlay updated: ${result.selection.name}`, "info");
|
|
424
513
|
return;
|
|
425
514
|
}
|
|
@@ -431,8 +520,7 @@ export default function piProfileExtension(pi: ExtensionAPI): void {
|
|
|
431
520
|
const deps = await activationDeps(ctx, false);
|
|
432
521
|
const target = { profile: { name: current.selection.name, source: current.selection.source } };
|
|
433
522
|
const result = await resetOverlay({ ...deps, ...target });
|
|
434
|
-
|
|
435
|
-
reportWarnings(ctx, result.warnings);
|
|
523
|
+
setCurrent(ctx, activationOf(result, deps.live.skills !== undefined));
|
|
436
524
|
notify(ctx, `overlay cleared: ${result.selection.name}`, "info");
|
|
437
525
|
return;
|
|
438
526
|
}
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"additionalProperties": false,
|
|
11
11
|
"properties": {
|
|
12
12
|
"schemaVersion": {
|
|
13
|
+
"description": "1 is current. 2 is the number v0.1.0 wrote for the same field shape and still reads; saves always write 1.",
|
|
13
14
|
"enum": [1, 2]
|
|
14
15
|
},
|
|
15
16
|
"profiles": {
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
"items": {
|
|
49
50
|
"type": "string"
|
|
50
51
|
},
|
|
51
|
-
"description": "
|
|
52
|
+
"description": "Ignored. Extensions always load natively (ADR-0007) and are managed with pi install; the field is accepted so existing catalogs keep validating."
|
|
52
53
|
},
|
|
53
54
|
"mcp": {
|
|
54
55
|
"type": "array",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile badge: the persistent, one-line answer to "which profile is this
|
|
3
|
+
* session running?".
|
|
4
|
+
*
|
|
5
|
+
* Presentation only — no state, no I/O, no activation. The extension decides
|
|
6
|
+
* when a badge is written (after an activation that succeeded, via
|
|
7
|
+
* `ctx.ui.setStatus(PROFILE_STATUS_KEY, …)`), so the badge can never claim a
|
|
8
|
+
* selection that was not applied.
|
|
9
|
+
*
|
|
10
|
+
* One canonical rendering, shared with the `/profile status` heading
|
|
11
|
+
* (`profile: <name>`), plus `*` when a runtime overlay is in effect — the only
|
|
12
|
+
* runtime difference the catalog does not show.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
|
|
17
|
+
import { DEFAULT_PROFILE_NAME } from "./profile-catalog.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Status key in Pi's footer. Pi joins all extension statuses into one line,
|
|
21
|
+
* orders them by key, and truncates that line from the right — so an earlier
|
|
22
|
+
* key keeps its text visible on a narrow terminal. `active-profile` sorts
|
|
23
|
+
* before the keys it shares the line with (`mcp`, `pi-…`, `thinking`).
|
|
24
|
+
*/
|
|
25
|
+
export const PROFILE_STATUS_KEY = "active-profile";
|
|
26
|
+
|
|
27
|
+
/** The label prefix, matching the `/profile status` heading. */
|
|
28
|
+
export const PROFILE_BADGE_LABEL = "profile";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Display columns reserved for the name before it is elided. The footer is a
|
|
32
|
+
* shared, fixed-width line: profile names are unbounded user input, so an
|
|
33
|
+
* unelided name would evict the statuses of other extensions.
|
|
34
|
+
*/
|
|
35
|
+
export const PROFILE_BADGE_NAME_COLUMNS = 16;
|
|
36
|
+
|
|
37
|
+
const ELLIPSIS = "…";
|
|
38
|
+
|
|
39
|
+
export interface ProfileBadge {
|
|
40
|
+
/** The name `/profile use` accepts (never the display `label`). */
|
|
41
|
+
name: string;
|
|
42
|
+
/** A runtime overlay is in effect: this runtime differs from the catalog. */
|
|
43
|
+
overlay: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The minimum theme surface the badge needs; `ctx.ui.theme` satisfies it. */
|
|
47
|
+
export interface BadgeTheme {
|
|
48
|
+
fg(color: ThemeColor, text: string): string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BadgeOptions {
|
|
52
|
+
overlay: boolean;
|
|
53
|
+
/** Override the name budget (tests, future width awareness). */
|
|
54
|
+
nameColumns?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Builds the badge for a resolved profile, or `undefined` when there must be
|
|
59
|
+
* no badge at all.
|
|
60
|
+
*
|
|
61
|
+
* `default` is Pi's native baseline — it declares nothing — so it must not
|
|
62
|
+
* change the footer either: a plain Pi session shows no badge, and the footer
|
|
63
|
+
* status line only exists while some extension status is set.
|
|
64
|
+
*/
|
|
65
|
+
export function buildProfileBadge(name: string, options: BadgeOptions): ProfileBadge | undefined {
|
|
66
|
+
if (name === DEFAULT_PROFILE_NAME) return undefined;
|
|
67
|
+
return {
|
|
68
|
+
name: truncateToColumns(name, options.nameColumns ?? PROFILE_BADGE_NAME_COLUMNS),
|
|
69
|
+
overlay: options.overlay,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Renders the badge. Colors come from the caller's theme at render time: Pi
|
|
75
|
+
* stores footer statuses as finished strings, so the extension re-renders on
|
|
76
|
+
* profile changes and on each turn (there is no extension-visible theme-change
|
|
77
|
+
* event).
|
|
78
|
+
*/
|
|
79
|
+
export function renderProfileBadge(badge: ProfileBadge, theme: BadgeTheme): string {
|
|
80
|
+
const label = theme.fg("dim", `${PROFILE_BADGE_LABEL}: `);
|
|
81
|
+
const name = theme.fg("dim", badge.name);
|
|
82
|
+
return badge.overlay ? `${label}${name}${theme.fg("warning", "*")}` : `${label}${name}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* -------------------------------------------------------------------------- */
|
|
86
|
+
/* Display width */
|
|
87
|
+
/* -------------------------------------------------------------------------- */
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Width of one code point in terminal columns: 2 for East Asian wide and
|
|
91
|
+
* fullwidth code points, 1 otherwise. Zero-width joiners and combining marks
|
|
92
|
+
* are counted as 1 — an approximation that only over-reserves space for
|
|
93
|
+
* exotic names.
|
|
94
|
+
*/
|
|
95
|
+
export function codePointWidth(codePoint: number): number {
|
|
96
|
+
return isWide(codePoint) ? 2 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Display width of `text` in terminal columns. */
|
|
100
|
+
export function displayWidth(text: string): number {
|
|
101
|
+
let width = 0;
|
|
102
|
+
for (const character of text) width += codePointWidth(character.codePointAt(0)!);
|
|
103
|
+
return width;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Truncates `text` to at most `columns` terminal columns, appending `…` when
|
|
108
|
+
* something was dropped. The ellipsis is part of the budget, so the result
|
|
109
|
+
* never exceeds `columns`.
|
|
110
|
+
*/
|
|
111
|
+
export function truncateToColumns(text: string, columns: number, ellipsis = ELLIPSIS): string {
|
|
112
|
+
if (columns <= 0) return "";
|
|
113
|
+
if (displayWidth(text) <= columns) return text;
|
|
114
|
+
const budget = columns - displayWidth(ellipsis);
|
|
115
|
+
let result = "";
|
|
116
|
+
let width = 0;
|
|
117
|
+
for (const character of text) {
|
|
118
|
+
const next = width + codePointWidth(character.codePointAt(0)!);
|
|
119
|
+
if (next > budget) break;
|
|
120
|
+
result += character;
|
|
121
|
+
width = next;
|
|
122
|
+
}
|
|
123
|
+
return budget < 0 ? "" : `${result}${ellipsis}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isWide(codePoint: number): boolean {
|
|
127
|
+
return (
|
|
128
|
+
(codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
129
|
+
(codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK radicals, Kangxi, CJK symbols
|
|
130
|
+
(codePoint >= 0x3041 && codePoint <= 0x33ff) || // kana, CJK compatibility, CJK punctuation
|
|
131
|
+
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK unified ideographs extension A
|
|
132
|
+
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK unified ideographs
|
|
133
|
+
(codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi syllables
|
|
134
|
+
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul syllables
|
|
135
|
+
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK compatibility ideographs
|
|
136
|
+
(codePoint >= 0xfe30 && codePoint <= 0xfe6f) || // CJK compatibility forms
|
|
137
|
+
(codePoint >= 0xff00 && codePoint <= 0xff60) || // fullwidth forms
|
|
138
|
+
(codePoint >= 0xffe0 && codePoint <= 0xffe6) || // fullwidth signs
|
|
139
|
+
(codePoint >= 0x1f300 && codePoint <= 0x1faff) || // emoji, pictographs
|
|
140
|
+
(codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK unified ideographs extension B+
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -3,9 +3,8 @@
|
|
|
3
3
|
* separate from the read-only ProfileCatalog.
|
|
4
4
|
*
|
|
5
5
|
* Invariants:
|
|
6
|
-
* - Whole-file overwrites (pretty-printed, `schemaVersion
|
|
7
|
-
*
|
|
8
|
-
* rewritten as version 2 on the next save; wizard saves never block on
|
|
6
|
+
* - Whole-file overwrites (pretty-printed, `schemaVersion 1` envelope, the
|
|
7
|
+
* constant owned by profile-catalog.ts). Wizard saves never block on
|
|
9
8
|
* concurrent edits — re-read at write time, same-name conflicts resolve
|
|
10
9
|
* last-write-wins.
|
|
11
10
|
* - Definitions are complete and self-contained: no inheritance fields
|
|
@@ -38,7 +37,7 @@ export class ProfileCatalogStore {
|
|
|
38
37
|
|
|
39
38
|
/** Validated definitions: missing file → empty; malformed → CatalogError
|
|
40
39
|
* (catalog errors never pass silently, even on the write path).
|
|
41
|
-
*
|
|
40
|
+
* Unknown fields (a legacy `extensions` declaration) are dropped. */
|
|
42
41
|
async readDefinitions(): Promise<Map<string, ProfileDefinition>> {
|
|
43
42
|
const result = await readJsonFile(this.#filePath);
|
|
44
43
|
if (!result.ok) {
|
|
@@ -48,7 +47,7 @@ export class ProfileCatalogStore {
|
|
|
48
47
|
if (!isRecord(result.value)) {
|
|
49
48
|
throw new CatalogError(`${this.#filePath}: catalog must be an object`);
|
|
50
49
|
}
|
|
51
|
-
return parseCatalogDocument(result.value, this.#filePath)
|
|
50
|
+
return parseCatalogDocument(result.value, this.#filePath);
|
|
52
51
|
}
|
|
53
52
|
|
|
54
53
|
/** Overwrites the file with the given definitions (last write wins). */
|
package/src/profile-catalog.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
* - A profile references skills, MCP servers, and tools, and may declare
|
|
8
8
|
* instructions and a model preset. Extensions are not a profile resource:
|
|
9
9
|
* every installed extension loads natively in every profile.
|
|
10
|
-
* - schemaVersion
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* - schemaVersion 1 is current. Version 2 describes the same fields — the
|
|
11
|
+
* number only marked the era in which `extensions` was still a profile
|
|
12
|
+
* resource — and reads the same; every save writes version 1.
|
|
13
|
+
* - A legacy `extensions` field is ignored silently, like any other unknown
|
|
14
|
+
* field: extensions load natively and are managed with `pi install`.
|
|
13
15
|
*
|
|
14
16
|
* Invariants:
|
|
15
17
|
* - The built-in `default` profile never exists in either file and cannot be
|
|
@@ -27,7 +29,9 @@ import path from "node:path";
|
|
|
27
29
|
|
|
28
30
|
import { isRecord, readJsonFile } from "./json-file.ts";
|
|
29
31
|
|
|
30
|
-
export const PROFILE_SCHEMA_VERSION =
|
|
32
|
+
export const PROFILE_SCHEMA_VERSION = 1;
|
|
33
|
+
/** The number v0.1.0 wrote for the same field shape: read, never written. */
|
|
34
|
+
const LEGACY_SCHEMA_VERSION = 2;
|
|
31
35
|
export const DEFAULT_PROFILE_NAME = "default";
|
|
32
36
|
|
|
33
37
|
export interface ProfileModel {
|
|
@@ -62,13 +66,6 @@ export interface ResolvedProfile {
|
|
|
62
66
|
definition: ProfileDefinition;
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
/** One parsed catalog file: definitions plus non-fatal compatibility
|
|
66
|
-
* warnings the caller surfaces once per activation. */
|
|
67
|
-
export interface CatalogDocument {
|
|
68
|
-
profiles: Map<string, ProfileDefinition>;
|
|
69
|
-
warnings: string[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
69
|
export class CatalogError extends Error {
|
|
73
70
|
constructor(message: string) {
|
|
74
71
|
super(message);
|
|
@@ -94,8 +91,8 @@ function readOptionalString(value: unknown, field: string, profileName: string):
|
|
|
94
91
|
|
|
95
92
|
/** Parses one raw profile definition; exported for the write-side store
|
|
96
93
|
* (profile-catalog-store.ts) so anything written is loadable. Unknown
|
|
97
|
-
* fields are ignored by design — `extensions` is
|
|
98
|
-
*
|
|
94
|
+
* fields are ignored by design — a legacy `extensions` declaration is
|
|
95
|
+
* dropped silently, exactly like any other unknown key. */
|
|
99
96
|
export function parseProfileDefinition(name: string, raw: unknown): ProfileDefinition {
|
|
100
97
|
if (!isRecord(raw)) {
|
|
101
98
|
throw new CatalogError(`profile "${name}" must be an object`);
|
|
@@ -123,22 +120,16 @@ export function parseProfileDefinition(name: string, raw: unknown): ProfileDefin
|
|
|
123
120
|
|
|
124
121
|
/** Parses one catalog document. Missing files are handled by the caller;
|
|
125
122
|
* this function sees only parsed JSON. */
|
|
126
|
-
export function parseCatalogDocument(value: unknown, filePath: string):
|
|
123
|
+
export function parseCatalogDocument(value: unknown, filePath: string): Map<string, ProfileDefinition> {
|
|
127
124
|
if (!isRecord(value)) {
|
|
128
125
|
throw new CatalogError(`${filePath}: catalog must be an object`);
|
|
129
126
|
}
|
|
130
|
-
const warnings: string[] = [];
|
|
131
127
|
const version = value.schemaVersion;
|
|
132
|
-
if (version !==
|
|
128
|
+
if (version !== PROFILE_SCHEMA_VERSION && version !== LEGACY_SCHEMA_VERSION) {
|
|
133
129
|
throw new CatalogError(
|
|
134
130
|
`${filePath}: unsupported schemaVersion ${JSON.stringify(version)} (expected ${PROFILE_SCHEMA_VERSION})`,
|
|
135
131
|
);
|
|
136
132
|
}
|
|
137
|
-
if (version === 1) {
|
|
138
|
-
warnings.push(
|
|
139
|
-
`${filePath}: schemaVersion 1 is read as version ${PROFILE_SCHEMA_VERSION}; profiles declaring "extensions" are upgraded with that field ignored`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
133
|
if (!isRecord(value.profiles)) {
|
|
143
134
|
throw new CatalogError(`${filePath}: "profiles" must be an object mapping names to definitions`);
|
|
144
135
|
}
|
|
@@ -149,21 +140,16 @@ export function parseCatalogDocument(value: unknown, filePath: string): CatalogD
|
|
|
149
140
|
`${filePath}: "${DEFAULT_PROFILE_NAME}" is built in and must not be defined in the catalog`,
|
|
150
141
|
);
|
|
151
142
|
}
|
|
152
|
-
if (isRecord(raw) && raw.extensions !== undefined) {
|
|
153
|
-
warnings.push(
|
|
154
|
-
`${filePath}: profile "${name}" declares "extensions"; extensions are always loaded natively (ADR-0007) and the field is ignored — manage extensions with pi install`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
143
|
profiles.set(name, parseProfileDefinition(name, raw));
|
|
158
144
|
}
|
|
159
|
-
return
|
|
145
|
+
return profiles;
|
|
160
146
|
}
|
|
161
147
|
|
|
162
148
|
/** Reads one catalog file; missing → empty map, malformed → CatalogError. */
|
|
163
|
-
async function loadCatalogFile(catalogPath: string): Promise<
|
|
149
|
+
async function loadCatalogFile(catalogPath: string): Promise<Map<string, ProfileDefinition>> {
|
|
164
150
|
const result = await readJsonFile(catalogPath);
|
|
165
151
|
if (!result.ok) {
|
|
166
|
-
if (result.reason === "missing") return
|
|
152
|
+
if (result.reason === "missing") return new Map();
|
|
167
153
|
throw new CatalogError(`invalid JSON in ${catalogPath}`);
|
|
168
154
|
}
|
|
169
155
|
return parseCatalogDocument(result.value, catalogPath);
|
|
@@ -171,17 +157,9 @@ async function loadCatalogFile(catalogPath: string): Promise<CatalogDocument> {
|
|
|
171
157
|
|
|
172
158
|
export class ProfileCatalog {
|
|
173
159
|
readonly #profiles: ReadonlyMap<string, CatalogEntry>;
|
|
174
|
-
readonly #warnings: readonly string[];
|
|
175
160
|
|
|
176
|
-
private constructor(profiles: ReadonlyMap<string, CatalogEntry
|
|
161
|
+
private constructor(profiles: ReadonlyMap<string, CatalogEntry>) {
|
|
177
162
|
this.#profiles = profiles;
|
|
178
|
-
this.#warnings = warnings;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Compatibility warnings from reading the catalog files (v1 schema,
|
|
182
|
-
* ignored `extensions` fields). Empty for a current, well-formed pair. */
|
|
183
|
-
get warnings(): readonly string[] {
|
|
184
|
-
return this.#warnings;
|
|
185
163
|
}
|
|
186
164
|
|
|
187
165
|
/**
|
|
@@ -193,18 +171,16 @@ export class ProfileCatalog {
|
|
|
193
171
|
static async load(agentDir: string, options?: { projectDir?: string }): Promise<ProfileCatalog> {
|
|
194
172
|
const global = await loadCatalogFile(path.join(agentDir, "profiles.json"));
|
|
195
173
|
const profiles = new Map<string, CatalogEntry>();
|
|
196
|
-
const
|
|
197
|
-
for (const [name, definition] of global.profiles) {
|
|
174
|
+
for (const [name, definition] of global) {
|
|
198
175
|
profiles.set(name, { source: "global", definition });
|
|
199
176
|
}
|
|
200
177
|
if (options?.projectDir !== undefined) {
|
|
201
178
|
const project = await loadCatalogFile(path.join(options.projectDir, ".pi", "profiles.json"));
|
|
202
|
-
|
|
203
|
-
for (const [name, definition] of project.profiles) {
|
|
179
|
+
for (const [name, definition] of project) {
|
|
204
180
|
profiles.set(name, { source: "project", definition });
|
|
205
181
|
}
|
|
206
182
|
}
|
|
207
|
-
return new ProfileCatalog(profiles
|
|
183
|
+
return new ProfileCatalog(profiles);
|
|
208
184
|
}
|
|
209
185
|
|
|
210
186
|
/** Resolves a profile by name. `default` always resolves to the built-in
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfilePresets: the starting points `/profile create` offers.
|
|
3
|
+
*
|
|
4
|
+
* A preset is DATA, not a profile. It never appears in `/profile list` and
|
|
5
|
+
* cannot be activated until the create wizard copies it into a user catalog,
|
|
6
|
+
* so `default` stays the only built-in profile and nothing here is ever
|
|
7
|
+
* silently in effect. Once copied, the definition belongs to the user: the
|
|
8
|
+
* preset is not tracked, and later changes to it do not reach existing
|
|
9
|
+
* profiles.
|
|
10
|
+
*
|
|
11
|
+
* Every preset must work on a machine with no skills installed, no MCP
|
|
12
|
+
* adapter, and no provider credentials:
|
|
13
|
+
*
|
|
14
|
+
* - no `mcp`: a declared MCP intent fails the whole activation when
|
|
15
|
+
* `pi-mcp-adapter` is absent or the server was never discovered
|
|
16
|
+
* (ADR-0002).
|
|
17
|
+
* - no `model`: `provider` and `id` are required, and an unauthenticated
|
|
18
|
+
* model also fails the whole activation.
|
|
19
|
+
* - no `skills`: a literal reference warns once the skill turns out to be
|
|
20
|
+
* missing, and `[]` hides every skill — omitting the field keeps Pi's full
|
|
21
|
+
* visibility.
|
|
22
|
+
* - `tools` names Pi's built-in tools only, because extension and MCP tool
|
|
23
|
+
* names may never register, and it always contains `read`: Pi emits the
|
|
24
|
+
* prompt's skills section only while `read` or `bash` is active.
|
|
25
|
+
* - `instructions` states behavior, never a capability name, and stays
|
|
26
|
+
* short: Pi appends it to the system prompt on every turn.
|
|
27
|
+
*
|
|
28
|
+
* `test/profile-presets.test.ts` enforces every rule above and asserts
|
|
29
|
+
* `examples/profiles.json` is exactly this catalog.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ProfileDefinition } from "./profile-catalog.ts";
|
|
33
|
+
|
|
34
|
+
export interface ProfilePreset {
|
|
35
|
+
/** Catalog key the create wizard offers as the new profile's name. */
|
|
36
|
+
name: string;
|
|
37
|
+
definition: ProfileDefinition;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The read-only behavior contract: describe, do not mutate. */
|
|
41
|
+
const READ_ONLY_INSTRUCTIONS = [
|
|
42
|
+
"Read-only session: inspect and report; never create, edit, rename, or delete files.",
|
|
43
|
+
"If a change is needed, describe it in your reply instead of applying it.",
|
|
44
|
+
"Do not run commands that modify state (installs, formatters, commits, pushes, network writes).",
|
|
45
|
+
"Prefer an available skill or MCP tool when it fits the request; otherwise use the tools you have.",
|
|
46
|
+
"Ground claims in evidence: cite file:line and separate verified facts from inferences.",
|
|
47
|
+
"Reply in English.",
|
|
48
|
+
].join("\n");
|
|
49
|
+
|
|
50
|
+
export const PROFILE_PRESETS: readonly ProfilePreset[] = [
|
|
51
|
+
{
|
|
52
|
+
name: "read-only",
|
|
53
|
+
definition: {
|
|
54
|
+
label: "Read-only",
|
|
55
|
+
description: "Read-only session; no skills or MCP servers assumed — add your own.",
|
|
56
|
+
tools: ["read", "grep", "find", "ls"],
|
|
57
|
+
instructions: READ_ONLY_INSTRUCTIONS,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
package/src/profile-resolver.ts
CHANGED
|
@@ -23,7 +23,11 @@ import type { RuntimeOverlay } from "./runtime-state-store.ts";
|
|
|
23
23
|
|
|
24
24
|
/** The live resource view a resolution runs against. */
|
|
25
25
|
export interface LiveResources {
|
|
26
|
-
skills
|
|
26
|
+
/** Pi's loaded skills. `undefined` when the caller cannot read them yet:
|
|
27
|
+
* Pi exposes the list on command contexts and on the `before_agent_start`
|
|
28
|
+
* event, not in `session_start`'s event context. An unknown set leaves the
|
|
29
|
+
* visibility filter intact and reports nothing (see `skillWarnings`). */
|
|
30
|
+
skills?: Array<{ name: string; filePath: string }>;
|
|
27
31
|
toolNames: string[];
|
|
28
32
|
/** MCP adapter state: presence plus discovered server names. */
|
|
29
33
|
mcp: { adapterPresent: boolean; servers: string[] };
|
|
@@ -77,17 +81,21 @@ export class SelectionError extends Error {
|
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
/** Existence warnings for skill references against one live skill set.
|
|
85
|
+
* `undefined` means "not known yet" — Pi's skill list is only readable from
|
|
86
|
+
* a command context or from the `before_agent_start` event, so a startup
|
|
87
|
+
* activation reports nothing instead of every reference as unloaded. The
|
|
88
|
+
* same function re-checks the references on the first turn, when the list
|
|
89
|
+
* (including skills contributed through `resources_discover`) is complete. */
|
|
90
|
+
export function skillWarnings(
|
|
91
|
+
refs: readonly string[],
|
|
83
92
|
live: LiveResources["skills"],
|
|
84
|
-
):
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
93
|
+
): Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> {
|
|
94
|
+
const warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> = {
|
|
95
|
+
skillsUnresolved: [],
|
|
96
|
+
skillsUnmatched: [],
|
|
97
|
+
};
|
|
98
|
+
if (live === undefined) return warning;
|
|
91
99
|
for (const ref of refs) {
|
|
92
100
|
const hits = live.filter((skill) => matchesReference(ref, skill.name));
|
|
93
101
|
if (hits.length === 0) {
|
|
@@ -95,7 +103,22 @@ function resolveSkills(
|
|
|
95
103
|
else warning.skillsUnresolved.push({ reference: ref, suggestions: suggestNames(ref, live.map((s) => s.name)) });
|
|
96
104
|
}
|
|
97
105
|
}
|
|
98
|
-
return
|
|
106
|
+
return warning;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveSkills(
|
|
110
|
+
declared: string[] | undefined,
|
|
111
|
+
disabled: string[],
|
|
112
|
+
live: LiveResources["skills"],
|
|
113
|
+
): { filter?: SkillsFilter; warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched"> } {
|
|
114
|
+
if (declared === undefined) {
|
|
115
|
+
// The profile declares nothing; an overlay may still hide skills.
|
|
116
|
+
return {
|
|
117
|
+
...(disabled.length > 0 ? { filter: { refs: "all" as const, disabled } } : {}),
|
|
118
|
+
warning: skillWarnings([], live),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return { filter: { refs: declared, disabled }, warning: skillWarnings(declared, live) };
|
|
99
122
|
}
|
|
100
123
|
|
|
101
124
|
function resolveMcp(
|
|
@@ -211,23 +234,33 @@ export function resolveSelection(input: {
|
|
|
211
234
|
return selection;
|
|
212
235
|
}
|
|
213
236
|
|
|
214
|
-
/** User-facing
|
|
215
|
-
|
|
237
|
+
/** User-facing lines for one set of skill warnings. Shared by the startup
|
|
238
|
+
* report and the first-turn re-check, so both read identically. */
|
|
239
|
+
export function formatSkillWarnings(
|
|
240
|
+
profile: string,
|
|
241
|
+
warning: Pick<SelectionWarnings, "skillsUnresolved" | "skillsUnmatched">,
|
|
242
|
+
): string[] {
|
|
216
243
|
const lines: string[] = [];
|
|
217
|
-
for (const unresolved of
|
|
244
|
+
for (const unresolved of warning.skillsUnresolved) {
|
|
218
245
|
const hint =
|
|
219
246
|
unresolved.suggestions.length > 0
|
|
220
247
|
? ` — did you mean: ${unresolved.suggestions.map((name) => JSON.stringify(name)).join(", ")}?`
|
|
221
248
|
: "";
|
|
222
249
|
lines.push(
|
|
223
|
-
`profile "${
|
|
250
|
+
`profile "${profile}": skill ${JSON.stringify(unresolved.reference)} is not loaded in this session${hint}`,
|
|
224
251
|
);
|
|
225
252
|
}
|
|
226
|
-
if (
|
|
253
|
+
if (warning.skillsUnmatched.length > 0) {
|
|
227
254
|
lines.push(
|
|
228
|
-
`profile "${
|
|
255
|
+
`profile "${profile}": skill glob(s) ${warning.skillsUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
|
|
229
256
|
);
|
|
230
257
|
}
|
|
258
|
+
return lines;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** User-facing warning lines for one resolved selection. */
|
|
262
|
+
export function formatSelectionWarnings(selection: ResolvedSelection): string[] {
|
|
263
|
+
const lines: string[] = formatSkillWarnings(selection.name, selection.warnings);
|
|
231
264
|
if (selection.warnings.mcpUnmatched.length > 0) {
|
|
232
265
|
lines.push(
|
|
233
266
|
`profile "${selection.name}": MCP glob(s) ${selection.warnings.mcpUnmatched.map((ref) => JSON.stringify(ref)).join(", ")} matched nothing`,
|
|
@@ -42,6 +42,19 @@ export interface RuntimeOverlay {
|
|
|
42
42
|
tools?: string[];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** True when an overlay actually narrows the active profile. An overlay whose
|
|
46
|
+
* fields were all removed again (`/profile customize enable …`) is not a
|
|
47
|
+
* difference from the catalog, and `parseOverlay` drops an empty overlay on
|
|
48
|
+
* read. A `tools: []` override is a difference: it selects no tools. */
|
|
49
|
+
export function overlayNarrows(overlay: RuntimeOverlay | undefined): boolean {
|
|
50
|
+
if (overlay === undefined) return false;
|
|
51
|
+
return (
|
|
52
|
+
(overlay.disabledSkills?.length ?? 0) > 0 ||
|
|
53
|
+
(overlay.disabledMcp?.length ?? 0) > 0 ||
|
|
54
|
+
overlay.tools !== undefined
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
45
58
|
function parseOverlay(value: unknown): RuntimeOverlay | undefined {
|
|
46
59
|
if (!isRecord(value)) return undefined;
|
|
47
60
|
const overlay: RuntimeOverlay = {};
|
package/src/startup-selection.ts
CHANGED
|
@@ -105,10 +105,7 @@ export async function resolveStartupProfile(input: {
|
|
|
105
105
|
const catalog = await ProfileCatalog.load(input.agentDir, {
|
|
106
106
|
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
107
107
|
});
|
|
108
|
-
const warnings: string[] =
|
|
109
|
-
...catalog.warnings,
|
|
110
|
-
...(await legacyRegistryWarnings(input)),
|
|
111
|
-
];
|
|
108
|
+
const warnings: string[] = await legacyRegistryWarnings(input);
|
|
112
109
|
|
|
113
110
|
if (input.requested !== undefined) {
|
|
114
111
|
if (catalog.resolve(input.requested) === undefined) {
|
|
@@ -53,7 +53,10 @@ export interface ActivationDeps {
|
|
|
53
53
|
|
|
54
54
|
export interface ActivationResult {
|
|
55
55
|
selection: ResolvedSelection;
|
|
56
|
-
|
|
56
|
+
/** The overlay this activation applied; absent when the runtime runs the
|
|
57
|
+
* profile exactly as it is declared. Callers surface it (the footer badge)
|
|
58
|
+
* instead of re-reading the state file. */
|
|
59
|
+
overlay?: RuntimeOverlay;
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
/** Resolves a profile against the live resources without applying it.
|
|
@@ -65,7 +68,7 @@ export async function resolveProfileSelection(
|
|
|
65
68
|
name: string,
|
|
66
69
|
deps: Pick<ActivationDeps, "agentDir" | "cwd" | "projectTrusted" | "live" | "presetInputs">,
|
|
67
70
|
overlay?: RuntimeOverlay,
|
|
68
|
-
): Promise<
|
|
71
|
+
): Promise<ResolvedSelection> {
|
|
69
72
|
const catalog = await ProfileCatalog.load(deps.agentDir, {
|
|
70
73
|
projectDir: deps.projectTrusted ? deps.cwd : undefined,
|
|
71
74
|
});
|
|
@@ -76,8 +79,7 @@ export async function resolveProfileSelection(
|
|
|
76
79
|
);
|
|
77
80
|
}
|
|
78
81
|
const suppressTools = !deps.presetInputs.force && deps.presetInputs.explicit.tools;
|
|
79
|
-
|
|
80
|
-
return { selection, warnings: [...catalog.warnings] };
|
|
82
|
+
return resolveSelection({ profile, overlay, live: deps.live, suppressTools });
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
/** Activates a profile: resolve, validate, optionally persist, apply. */
|
|
@@ -87,7 +89,7 @@ export async function activateProfile(
|
|
|
87
89
|
options?: { overlay?: RuntimeOverlay | null; persist?: boolean },
|
|
88
90
|
): Promise<ActivationResult> {
|
|
89
91
|
const overlay = options?.overlay ?? undefined;
|
|
90
|
-
const
|
|
92
|
+
const selection = await resolveProfileSelection(name, deps, overlay);
|
|
91
93
|
const preset = decidePreset({ model: selection.model, ...deps.presetInputs });
|
|
92
94
|
|
|
93
95
|
// Validate before touching anything: the model preset is the only
|
|
@@ -109,5 +111,5 @@ export async function activateProfile(
|
|
|
109
111
|
throw new ActivationError(result.error ?? `profile "${name}" could not be applied`);
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
return { selection,
|
|
114
|
+
return { selection, ...(overlay === undefined ? {} : { overlay }) };
|
|
113
115
|
}
|
|
@@ -24,20 +24,17 @@ export async function listProfiles(input: {
|
|
|
24
24
|
realAgentDir: string;
|
|
25
25
|
cwd: string;
|
|
26
26
|
projectTrusted: boolean;
|
|
27
|
-
}): Promise<
|
|
27
|
+
}): Promise<ProfileListEntry[]> {
|
|
28
28
|
const catalog = await ProfileCatalog.load(input.realAgentDir, {
|
|
29
29
|
projectDir: input.projectTrusted ? input.cwd : undefined,
|
|
30
30
|
});
|
|
31
|
-
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
})),
|
|
39
|
-
warnings: [...catalog.warnings],
|
|
40
|
-
};
|
|
31
|
+
return catalog.list().map((profile) => ({
|
|
32
|
+
name: profile.name,
|
|
33
|
+
source: profile.source,
|
|
34
|
+
...(typeof profile.definition.label === "string" ? { label: profile.definition.label } : {}),
|
|
35
|
+
...(typeof profile.definition.description === "string" ? { description: profile.definition.description } : {}),
|
|
36
|
+
shadowsGlobal: profile.source === "project" && catalog.shadowsGlobal(profile.name),
|
|
37
|
+
}));
|
|
41
38
|
}
|
|
42
39
|
|
|
43
40
|
export function formatProfileList(entries: ProfileListEntry[], activeProfile?: string): string {
|
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* keeps the current value (prefill via placeholder); there is no
|
|
9
9
|
* field-clearing gesture (delete + create instead). Any cancelled step
|
|
10
10
|
* aborts the wizard — nothing is written.
|
|
11
|
+
*
|
|
12
|
+
* Create can start from a shipped preset (src/profile-presets.ts). A preset is
|
|
13
|
+
* prefilled form state only: the wizard copies its complete definition into the
|
|
14
|
+
* chosen catalog, and the new profile belongs to the user from then on.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { ProfileDefinition } from "../profile-catalog.ts";
|
|
18
|
+
import { PROFILE_PRESETS, type ProfilePreset } from "../profile-presets.ts";
|
|
14
19
|
import type { CatalogScope } from "./profile-crud.ts";
|
|
15
20
|
|
|
16
21
|
export interface ProfileWizardUi {
|
|
@@ -22,6 +27,8 @@ export interface ProfileWizardResult {
|
|
|
22
27
|
scope: CatalogScope;
|
|
23
28
|
name: string;
|
|
24
29
|
definition: ProfileDefinition;
|
|
30
|
+
/** Name of the preset this definition was copied from, when any. */
|
|
31
|
+
preset?: string;
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
interface ExistingProfile {
|
|
@@ -108,23 +115,49 @@ async function captureDefinition(
|
|
|
108
115
|
return definition;
|
|
109
116
|
}
|
|
110
117
|
|
|
111
|
-
|
|
118
|
+
const BLANK_OPTION = "blank — start from an empty definition";
|
|
119
|
+
|
|
120
|
+
/** One preset row. The caller identifies the preset by the option's INDEX, so
|
|
121
|
+
* a preset named like another row's text cannot be misread. */
|
|
122
|
+
function presetOption(preset: ProfilePreset): string {
|
|
123
|
+
const hint = preset.definition.description ?? preset.definition.label;
|
|
124
|
+
return hint !== undefined ? `${preset.name} — ${hint}` : preset.name;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Create: scope, optional preset, name, then fields. */
|
|
112
128
|
export async function runProfileCreateWizard(
|
|
113
129
|
ui: ProfileWizardUi,
|
|
114
|
-
input: { projectTrusted: boolean },
|
|
130
|
+
input: { projectTrusted: boolean; presets?: readonly ProfilePreset[] },
|
|
115
131
|
): Promise<ProfileWizardResult | undefined> {
|
|
116
132
|
const scopeOptions = input.projectTrusted ? ["global", "project"] : ["global"];
|
|
117
133
|
// A single available scope needs no dialog.
|
|
118
134
|
const scope = scopeOptions.length === 1 ? scopeOptions[0] : await ui.select("write to which catalog?", scopeOptions);
|
|
119
135
|
if (scope === undefined) return undefined;
|
|
120
136
|
|
|
121
|
-
const
|
|
122
|
-
|
|
137
|
+
const presets = input.presets ?? PROFILE_PRESETS;
|
|
138
|
+
let preset: ProfilePreset | undefined;
|
|
139
|
+
if (presets.length > 0) {
|
|
140
|
+
const options = [BLANK_OPTION, ...presets.map(presetOption)];
|
|
141
|
+
const chosen = await ui.select("start from which preset?", options);
|
|
142
|
+
if (chosen === undefined) return undefined;
|
|
143
|
+
preset = presets[options.indexOf(chosen) - 1];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// An empty answer takes the preset's name, so Enter accepts the preset.
|
|
147
|
+
const name = await ui.input("profile name", preset?.name);
|
|
148
|
+
if (name === undefined) return undefined;
|
|
149
|
+
const resolvedName = name.trim().length > 0 ? name.trim() : preset?.name;
|
|
150
|
+
if (resolvedName === undefined) return undefined;
|
|
123
151
|
|
|
124
|
-
const definition = await captureDefinition(ui);
|
|
152
|
+
const definition = await captureDefinition(ui, preset?.definition);
|
|
125
153
|
if (definition === undefined) return undefined;
|
|
126
154
|
|
|
127
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
scope: scope as CatalogScope,
|
|
157
|
+
name: resolvedName,
|
|
158
|
+
definition,
|
|
159
|
+
...(preset !== undefined ? { preset: preset.name } : {}),
|
|
160
|
+
};
|
|
128
161
|
}
|
|
129
162
|
|
|
130
163
|
/** Edit: fields prefilled from the existing complete definition. */
|