pi-model-profiles 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.
@@ -0,0 +1,249 @@
1
+ import { PROFILE_FIELD_KEYS, type ProfileFieldKey, type ProfileFields } from "./types.js";
2
+ import { ModelProfilesError } from "./errors.js";
3
+
4
+ interface FrontmatterDocument {
5
+ frontmatter: string;
6
+ body: string;
7
+ }
8
+
9
+ interface FrontmatterBlock {
10
+ key: string | null;
11
+ lines: string[];
12
+ }
13
+
14
+ const ANCHOR_KEYS = new Set(["name", "mode", "color", "description"]);
15
+ const SAFE_UNQUOTED_SCALAR = /^[A-Za-z0-9._/@:-]+$/;
16
+ const PROFILE_KEY_SET = new Set<string>(PROFILE_FIELD_KEYS);
17
+
18
+ function normalizeNewlines(value: string): string {
19
+ return value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
20
+ }
21
+
22
+ function extractTopLevelKey(line: string): string | null {
23
+ if (!line || line.startsWith(" ") || line.startsWith("\t")) {
24
+ return null;
25
+ }
26
+
27
+ const match = /^([A-Za-z0-9_-]+):(?:\s*(.*))?$/.exec(line);
28
+ if (!match) {
29
+ return null;
30
+ }
31
+
32
+ return match[1] ?? null;
33
+ }
34
+
35
+ function parseScalarText(value: string): string {
36
+ const trimmed = value.trim();
37
+ if (!trimmed) {
38
+ return "";
39
+ }
40
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
41
+ try {
42
+ return JSON.parse(trimmed) as string;
43
+ } catch {
44
+ return trimmed.slice(1, -1);
45
+ }
46
+ }
47
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
48
+ return trimmed.slice(1, -1).replace(/''/g, "'");
49
+ }
50
+ return trimmed;
51
+ }
52
+
53
+ function splitFrontmatterBlocks(frontmatter: string): FrontmatterBlock[] {
54
+ const lines = frontmatter.split("\n");
55
+ const starts: Array<{ index: number; key: string }> = [];
56
+
57
+ for (let index = 0; index < lines.length; index += 1) {
58
+ const key = extractTopLevelKey(lines[index] ?? "");
59
+ if (key) {
60
+ starts.push({ index, key });
61
+ }
62
+ }
63
+
64
+ if (starts.length === 0) {
65
+ return [{ key: null, lines }];
66
+ }
67
+
68
+ const blocks: FrontmatterBlock[] = [];
69
+ if ((starts[0]?.index ?? 0) > 0) {
70
+ blocks.push({ key: null, lines: lines.slice(0, starts[0]?.index) });
71
+ }
72
+
73
+ for (let index = 0; index < starts.length; index += 1) {
74
+ const current = starts[index];
75
+ const next = starts[index + 1];
76
+ blocks.push({
77
+ key: current?.key ?? null,
78
+ lines: lines.slice(current?.index ?? 0, next ? next.index : lines.length),
79
+ });
80
+ }
81
+
82
+ return blocks.filter((block) => block.lines.length > 0);
83
+ }
84
+
85
+ function trimBlankEdges(lines: string[]): string[] {
86
+ let start = 0;
87
+ let end = lines.length;
88
+ while (start < end && !(lines[start] ?? "").trim()) {
89
+ start += 1;
90
+ }
91
+ while (end > start && !(lines[end - 1] ?? "").trim()) {
92
+ end -= 1;
93
+ }
94
+ return lines.slice(start, end);
95
+ }
96
+
97
+ function joinBlocks(blocks: FrontmatterBlock[]): string {
98
+ const lines = blocks.flatMap((block) => block.lines);
99
+ return trimBlankEdges(lines).join("\n");
100
+ }
101
+
102
+ function serializeScalar(value: string): string {
103
+ return SAFE_UNQUOTED_SCALAR.test(value) ? value : JSON.stringify(value);
104
+ }
105
+
106
+ function serializeProfileLines(fields: ProfileFields): string[] {
107
+ const lines: string[] = [];
108
+ if (fields.model !== undefined) {
109
+ lines.push(`model: ${serializeScalar(fields.model)}`);
110
+ }
111
+ if (fields.temperature !== undefined) {
112
+ lines.push(`temperature: ${String(fields.temperature)}`);
113
+ }
114
+ if (fields.reasoningEffort !== undefined) {
115
+ lines.push(`reasoningEffort: ${serializeScalar(fields.reasoningEffort)}`);
116
+ }
117
+ return lines;
118
+ }
119
+
120
+ function findInsertIndex(blocks: FrontmatterBlock[]): number {
121
+ const firstProfileIndex = blocks.findIndex((block) => block.key !== null && PROFILE_KEY_SET.has(block.key));
122
+ if (firstProfileIndex !== -1) {
123
+ let keptBefore = 0;
124
+ for (let index = 0; index < firstProfileIndex; index += 1) {
125
+ if (!PROFILE_KEY_SET.has(blocks[index]?.key ?? "")) {
126
+ keptBefore += 1;
127
+ }
128
+ }
129
+ return keptBefore;
130
+ }
131
+
132
+ let lastAnchorIndex = -1;
133
+ for (let index = 0; index < blocks.length; index += 1) {
134
+ const key = blocks[index]?.key;
135
+ if (key && ANCHOR_KEYS.has(key)) {
136
+ lastAnchorIndex = index;
137
+ }
138
+ }
139
+
140
+ if (lastAnchorIndex !== -1) {
141
+ return lastAnchorIndex + 1;
142
+ }
143
+
144
+ return blocks[0]?.key === null ? 1 : 0;
145
+ }
146
+
147
+ export function extractFrontmatterDocument(markdown: string): FrontmatterDocument {
148
+ const normalized = normalizeNewlines(markdown);
149
+ const lines = normalized.split("\n");
150
+ if ((lines[0] ?? "") !== "---") {
151
+ throw new ModelProfilesError("Agent markdown is missing opening frontmatter delimiter.", "INVALID_FRONTMATTER");
152
+ }
153
+
154
+ let endIndex = -1;
155
+ for (let index = 1; index < lines.length; index += 1) {
156
+ if ((lines[index] ?? "") === "---") {
157
+ endIndex = index;
158
+ break;
159
+ }
160
+ }
161
+
162
+ if (endIndex === -1) {
163
+ throw new ModelProfilesError("Agent markdown is missing closing frontmatter delimiter.", "INVALID_FRONTMATTER");
164
+ }
165
+
166
+ return {
167
+ frontmatter: lines.slice(1, endIndex).join("\n"),
168
+ body: lines.slice(endIndex + 1).join("\n"),
169
+ };
170
+ }
171
+
172
+ export function readTopLevelScalarMap(frontmatter: string): Record<string, string> {
173
+ const values: Record<string, string> = {};
174
+ for (const line of frontmatter.split("\n")) {
175
+ const key = extractTopLevelKey(line);
176
+ if (!key) {
177
+ continue;
178
+ }
179
+ const separatorIndex = line.indexOf(":");
180
+ const rawValue = line.slice(separatorIndex + 1).trim();
181
+ if (!rawValue) {
182
+ continue;
183
+ }
184
+ values[key] = parseScalarText(rawValue);
185
+ }
186
+ return values;
187
+ }
188
+
189
+ export function readAgentNameFromMarkdown(markdown: string): string {
190
+ const { frontmatter } = extractFrontmatterDocument(markdown);
191
+ const name = readTopLevelScalarMap(frontmatter).name?.trim();
192
+ if (!name) {
193
+ throw new ModelProfilesError("Agent markdown frontmatter is missing a non-empty 'name' field.", "INVALID_AGENT_NAME");
194
+ }
195
+ return name;
196
+ }
197
+
198
+ export function readProfileFieldsFromMarkdown(markdown: string): ProfileFields {
199
+ const { frontmatter } = extractFrontmatterDocument(markdown);
200
+ const values = readTopLevelScalarMap(frontmatter);
201
+ const fields: ProfileFields = {};
202
+
203
+ const model = values.model?.trim();
204
+ if (model) {
205
+ fields.model = model;
206
+ }
207
+
208
+ if (values.temperature !== undefined) {
209
+ const parsedTemperature = Number.parseFloat(values.temperature);
210
+ if (!Number.isFinite(parsedTemperature)) {
211
+ throw new ModelProfilesError(
212
+ `Frontmatter field 'temperature' must be numeric, received '${values.temperature}'.`,
213
+ "INVALID_TEMPERATURE",
214
+ );
215
+ }
216
+ fields.temperature = parsedTemperature;
217
+ }
218
+
219
+ const reasoningEffort = values.reasoningEffort?.trim();
220
+ if (reasoningEffort) {
221
+ fields.reasoningEffort = reasoningEffort;
222
+ }
223
+
224
+ return fields;
225
+ }
226
+
227
+ export function updateMarkdownProfileFields(markdown: string, fields: ProfileFields): string {
228
+ const document = extractFrontmatterDocument(markdown);
229
+ const originalBlocks = splitFrontmatterBlocks(document.frontmatter);
230
+ const keptBlocks = originalBlocks.filter((block) => !PROFILE_KEY_SET.has(block.key ?? ""));
231
+ const insertIndex = findInsertIndex(originalBlocks);
232
+ const profileLines = serializeProfileLines(fields);
233
+ const nextBlocks = [...keptBlocks];
234
+
235
+ if (profileLines.length > 0) {
236
+ nextBlocks.splice(insertIndex, 0, { key: null, lines: profileLines });
237
+ }
238
+
239
+ const nextFrontmatter = joinBlocks(nextBlocks);
240
+ return `---\n${nextFrontmatter}\n---\n${document.body}`;
241
+ }
242
+
243
+ export function listAppliedKeys(fields: ProfileFields): ProfileFieldKey[] {
244
+ return PROFILE_FIELD_KEYS.filter((key) => fields[key] !== undefined);
245
+ }
246
+
247
+ export function listRemovedKeys(fields: ProfileFields): ProfileFieldKey[] {
248
+ return PROFILE_FIELD_KEYS.filter((key) => fields[key] === undefined);
249
+ }
@@ -0,0 +1,60 @@
1
+ import { AGENTS_DIR, INITIAL_PROFILE_NAME, PROFILE_STORE_PATH } from "./constants.js";
2
+ import { captureAgentSnapshots, type AgentSelectionOptions } from "./agent-writer.js";
3
+ import { appendProfile, createProfile, loadProfilesFile, resolveUniqueProfileName, saveProfilesFile } from "./profile-store.js";
4
+ import { loadMultiProfilesConfig } from "./config.js";
5
+ import { multiProfilesDebugLogger } from "./debug-logger.js";
6
+ import type { ImportProfilesResult, ProfilesFile } from "./types.js";
7
+
8
+ export function ensureProfilesImported(
9
+ data: ProfilesFile,
10
+ agentOptions: string | AgentSelectionOptions = AGENTS_DIR,
11
+ ): ImportProfilesResult {
12
+ if (data.importedAt) {
13
+ return {
14
+ data,
15
+ imported: false,
16
+ importedCount: 0,
17
+ warnings: [],
18
+ };
19
+ }
20
+
21
+ const snapshot = captureAgentSnapshots(agentOptions);
22
+ const timestamp = new Date().toISOString();
23
+ const profile = createProfile(resolveUniqueProfileName(INITIAL_PROFILE_NAME, data.profiles), snapshot.agents, { timestamp });
24
+
25
+ return {
26
+ data: {
27
+ ...appendProfile(data, profile),
28
+ importedAt: timestamp,
29
+ },
30
+ imported: true,
31
+ importedCount: 1,
32
+ warnings: snapshot.warnings,
33
+ };
34
+ }
35
+
36
+ export function loadAndPrepareProfiles(
37
+ storePath = PROFILE_STORE_PATH,
38
+ agentOptions: string | AgentSelectionOptions = AGENTS_DIR,
39
+ ): ImportProfilesResult {
40
+ const configLoad = loadMultiProfilesConfig();
41
+
42
+ multiProfilesDebugLogger.log("extension.initialized", {
43
+ configCreated: configLoad.created,
44
+ timestamp: new Date().toISOString(),
45
+ profilesVersion: 2,
46
+ });
47
+
48
+ const loaded = loadProfilesFile(storePath, agentOptions);
49
+ const prepared = ensureProfilesImported(loaded.data, agentOptions);
50
+
51
+ if (loaded.needsSave || prepared.imported) {
52
+ saveProfilesFile(prepared.data, storePath);
53
+ }
54
+
55
+ const warnings = [configLoad.warning, loaded.warning, ...prepared.warnings].filter((message): message is string => Boolean(message));
56
+ return {
57
+ ...prepared,
58
+ warnings,
59
+ };
60
+ }
package/src/index.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ import { COMMAND_NAME, INITIAL_PROFILE_NAME, PROFILE_NAME_SUFFIX, PROFILE_STORE_PATH } from "./constants.js";
4
+ import { applySavedProfile, captureAgentSnapshots, detectActiveAgentName } from "./agent-writer.js";
5
+ import { toErrorMessage } from "./errors.js";
6
+ import { loadAndPrepareProfiles } from "./import-service.js";
7
+ import { updateProfileAndReturn } from "./profile-update-service.js";
8
+ import { appendProfile, createProfile, findProfileById, renameStoredProfile, resolveUniqueProfileName, saveProfilesFile } from "./profile-store.js";
9
+ import { removeProfileAndUpdate } from "./profile-removal-service.js";
10
+ import { openProfilesModal, type ProfileModalResult } from "./profile-modal.js";
11
+ import { onResourcesDiscover, refreshModelRegistry } from "./pi-api-utils.js";
12
+ import type { AppliedProfileOutcome, ProfilesFile } from "./types.js";
13
+
14
+ function buildCurrentProfileName(activeAgentName: string | null, data: ProfilesFile): string {
15
+ const baseName = activeAgentName ? `${activeAgentName} ${PROFILE_NAME_SUFFIX}` : INITIAL_PROFILE_NAME;
16
+ return resolveUniqueProfileName(baseName, data.profiles);
17
+ }
18
+
19
+ function notifyWarnings(ctx: ExtensionCommandContext, warnings: readonly string[]): void {
20
+ if (warnings.length === 0) {
21
+ return;
22
+ }
23
+ ctx.ui.notify(warnings.join(" "), "warning");
24
+ }
25
+
26
+ function summarizeApplyOutcome(outcome: AppliedProfileOutcome): string {
27
+ const appliedCount = outcome.appliedAgents.length;
28
+ const missingCount = outcome.missingAgents.length;
29
+ const appliedLabel = `${appliedCount} agent file${appliedCount === 1 ? "" : "s"}`;
30
+ if (missingCount === 0) {
31
+ return appliedLabel;
32
+ }
33
+ return `${appliedLabel}; ${missingCount} missing`;
34
+ }
35
+
36
+ async function reloadAfterApply(ctx: ExtensionCommandContext, outcome: AppliedProfileOutcome): Promise<void> {
37
+ const summary = summarizeApplyOutcome(outcome);
38
+ ctx.ui.notify(`Profile '${outcome.profileName}' applied across ${summary}. Reloading…`, "info");
39
+
40
+ try {
41
+ await ctx.reload();
42
+ } catch (error) {
43
+ ctx.ui.notify(
44
+ `Profile '${outcome.profileName}' applied across ${summary}, but automatic reload failed: ${toErrorMessage(error)}. Run /reload.`,
45
+ "error",
46
+ );
47
+ }
48
+ }
49
+
50
+ async function handleModelProfilesCommand(ctx: ExtensionCommandContext): Promise<void> {
51
+ const agentOptions = { cwd: ctx.cwd, scope: "both" as const };
52
+ const prepared = loadAndPrepareProfiles(PROFILE_STORE_PATH, agentOptions);
53
+ notifyWarnings(ctx, prepared.warnings);
54
+
55
+ let data = prepared.data;
56
+ const activeAgentName = detectActiveAgentName(ctx.sessionManager, ctx.getSystemPrompt());
57
+
58
+ const result: ProfileModalResult = await openProfilesModal(ctx, data, activeAgentName, {
59
+ renameProfile: async (profileId, nextName) => {
60
+ data = renameStoredProfile(data, profileId, nextName);
61
+ saveProfilesFile(data, PROFILE_STORE_PATH);
62
+ return {
63
+ data,
64
+ message: `Renamed saved snapshot to '${nextName.trim()}'.`,
65
+ selectedProfileId: profileId,
66
+ };
67
+ },
68
+ addCurrentProfile: async () => {
69
+ const snapshot = captureAgentSnapshots(agentOptions);
70
+ notifyWarnings(ctx, snapshot.warnings);
71
+ const profile = createProfile(buildCurrentProfileName(activeAgentName, data), snapshot.agents);
72
+ data = appendProfile(data, profile);
73
+ saveProfilesFile(data, PROFILE_STORE_PATH);
74
+ return {
75
+ data,
76
+ message: `Saved current agents snapshot (${snapshot.agents.length} agents).`,
77
+ selectedProfileId: profile.id,
78
+ };
79
+ },
80
+ applyProfile: async (profileId) => {
81
+ const profile = findProfileById(data, profileId);
82
+ if (!profile) {
83
+ throw new Error(`Saved profile '${profileId}' was not found.`);
84
+ }
85
+ const applied = applySavedProfile(profile, agentOptions);
86
+ notifyWarnings(ctx, applied.warnings);
87
+ return {
88
+ ...applied,
89
+ profileName: profile.name,
90
+ };
91
+ },
92
+ removeProfile: async (profileId) => {
93
+ const profile = findProfileById(data, profileId);
94
+ if (!profile) {
95
+ throw new Error(`Saved profile '${profileId}' was not found.`);
96
+ }
97
+ const removal = removeProfileAndUpdate(data, profileId);
98
+ data = removal.data;
99
+ saveProfilesFile(data, PROFILE_STORE_PATH);
100
+ return {
101
+ data,
102
+ message: `Removed saved snapshot '${removal.result.removedProfileName}' (${removal.result.remainingCount} snapshots remaining).`,
103
+ selectedProfileId: data.profiles[0]?.id,
104
+ };
105
+ },
106
+ updateProfile: async (profileId) => {
107
+ const profile = findProfileById(data, profileId);
108
+ if (!profile) {
109
+ throw new Error(`Saved profile '${profileId}' was not found.`);
110
+ }
111
+ const update = updateProfileAndReturn(data, profileId, agentOptions);
112
+ notifyWarnings(ctx, update.result.warnings);
113
+ data = update.data;
114
+ saveProfilesFile(data, PROFILE_STORE_PATH);
115
+ return {
116
+ data,
117
+ message: `Updated '${profile.name}' with current agent state (${update.result.updatedAgents} agents).`,
118
+ selectedProfileId: profileId,
119
+ };
120
+ },
121
+ });
122
+
123
+ if (result.type === "applied") {
124
+ await reloadAfterApply(ctx, result.outcome);
125
+ return;
126
+ }
127
+ }
128
+
129
+ export default function modelProfilesExtension(pi: ExtensionAPI): void {
130
+ // Register handler to refresh model registry on /reload
131
+ // This works around the issue where AgentSession.reload() doesn't call ModelRegistry.refresh()
132
+ onResourcesDiscover(pi, (event, ctx): void => {
133
+ if (event.reason === "reload") {
134
+ refreshModelRegistry(ctx);
135
+ }
136
+ });
137
+
138
+ pi.registerCommand(COMMAND_NAME, {
139
+ description: "Open saved whole-agent model profile snapshots",
140
+ handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
141
+ if (args.trim()) {
142
+ ctx.ui.notify(`Usage: /${COMMAND_NAME}`, "warning");
143
+ return;
144
+ }
145
+
146
+ if (!ctx.hasUI) {
147
+ ctx.ui.notify(`/${COMMAND_NAME} requires interactive TUI mode.`, "warning");
148
+ return;
149
+ }
150
+
151
+ try {
152
+ await handleModelProfilesCommand(ctx);
153
+ } catch (error) {
154
+ ctx.ui.notify(`/${COMMAND_NAME} failed: ${toErrorMessage(error)}`, "error");
155
+ }
156
+ },
157
+ });
158
+ }