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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/config/config.example.json +10 -0
- package/index.ts +3 -0
- package/package.json +66 -0
- package/src/agent-writer.ts +331 -0
- package/src/atomic-write.ts +28 -0
- package/src/config.ts +250 -0
- package/src/constants.ts +99 -0
- package/src/debug-logger.ts +351 -0
- package/src/errors.ts +16 -0
- package/src/frontmatter-parser.ts +249 -0
- package/src/import-service.ts +60 -0
- package/src/index.ts +158 -0
- package/src/modal-theme.ts +334 -0
- package/src/pi-api-utils.ts +56 -0
- package/src/profile-fields.ts +83 -0
- package/src/profile-modal.ts +1175 -0
- package/src/profile-removal-service.ts +106 -0
- package/src/profile-sort-service.ts +105 -0
- package/src/profile-store.ts +418 -0
- package/src/profile-update-service.ts +134 -0
- package/src/types-shims.d.ts +121 -0
- package/src/types.ts +104 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { multiProfilesDebugLogger } from "./debug-logger.js";
|
|
2
|
+
import { ModelProfilesError } from "./errors.js";
|
|
3
|
+
import type { ProfilesFile, ProfileRemovalResult, SavedProfile } from "./types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Error code for profile not found during removal.
|
|
7
|
+
*/
|
|
8
|
+
export const PROFILE_NOT_FOUND_CODE = "PROFILE_NOT_FOUND";
|
|
9
|
+
|
|
10
|
+
export interface ProfileRemovalUpdateResult {
|
|
11
|
+
data: ProfilesFile;
|
|
12
|
+
result: ProfileRemovalResult;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProfileRemovalPlan {
|
|
16
|
+
profile: SavedProfile;
|
|
17
|
+
remainingProfiles: SavedProfile[];
|
|
18
|
+
result: ProfileRemovalResult;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createProfileNotFoundError(profileId: string): ModelProfilesError {
|
|
22
|
+
return new ModelProfilesError(
|
|
23
|
+
`Profile '${profileId}' not found. It may have been removed already.`,
|
|
24
|
+
PROFILE_NOT_FOUND_CODE,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildRemovalPlan(data: ProfilesFile, profileId: string): ProfileRemovalPlan {
|
|
29
|
+
const profileIndex = data.profiles.findIndex((profile) => profile.id === profileId);
|
|
30
|
+
const profile = data.profiles[profileIndex];
|
|
31
|
+
|
|
32
|
+
if (profileIndex === -1 || !profile) {
|
|
33
|
+
multiProfilesDebugLogger.log("profile-removal", {
|
|
34
|
+
event: "removal_failed",
|
|
35
|
+
profileId,
|
|
36
|
+
reason: "profile_not_found",
|
|
37
|
+
profileCount: data.profiles.length,
|
|
38
|
+
});
|
|
39
|
+
throw createProfileNotFoundError(profileId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const remainingProfiles = [
|
|
43
|
+
...data.profiles.slice(0, profileIndex),
|
|
44
|
+
...data.profiles.slice(profileIndex + 1),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
profile,
|
|
49
|
+
remainingProfiles,
|
|
50
|
+
result: {
|
|
51
|
+
removedProfileId: profile.id,
|
|
52
|
+
removedProfileName: profile.name,
|
|
53
|
+
remainingCount: remainingProfiles.length,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function logProfileRemoved(plan: ProfileRemovalPlan): void {
|
|
59
|
+
multiProfilesDebugLogger.log("profile-removal", {
|
|
60
|
+
event: "profile_removed",
|
|
61
|
+
profileId: plan.profile.id,
|
|
62
|
+
profileName: plan.profile.name,
|
|
63
|
+
agentCount: plan.profile.agents.length,
|
|
64
|
+
remainingCount: plan.result.remainingCount,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remove a profile from the profiles file.
|
|
70
|
+
*
|
|
71
|
+
* Validates that the profile exists before removal.
|
|
72
|
+
* Preserves other profiles unchanged (immutable pattern).
|
|
73
|
+
* Logs removal event via debug logger.
|
|
74
|
+
*
|
|
75
|
+
* @param data - The profiles file data
|
|
76
|
+
* @param profileId - The ID of the profile to remove
|
|
77
|
+
* @returns ProfileRemovalResult with removed profile info and remaining count
|
|
78
|
+
* @throws ModelProfilesError with code PROFILE_NOT_FOUND if profile doesn't exist
|
|
79
|
+
*/
|
|
80
|
+
export function removeProfile(data: ProfilesFile, profileId: string): ProfileRemovalResult {
|
|
81
|
+
const plan = buildRemovalPlan(data, profileId);
|
|
82
|
+
logProfileRemoved(plan);
|
|
83
|
+
return plan.result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remove a profile and return the updated profiles file with removal metadata.
|
|
88
|
+
*
|
|
89
|
+
* @param data - The profiles file data
|
|
90
|
+
* @param profileId - The ID of the profile to remove
|
|
91
|
+
* @returns New ProfilesFile with the profile removed and removal metadata
|
|
92
|
+
* @throws ModelProfilesError with code PROFILE_NOT_FOUND if profile doesn't exist
|
|
93
|
+
*/
|
|
94
|
+
export function removeProfileAndUpdate(data: ProfilesFile, profileId: string): ProfileRemovalUpdateResult {
|
|
95
|
+
const plan = buildRemovalPlan(data, profileId);
|
|
96
|
+
logProfileRemoved(plan);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
data: {
|
|
100
|
+
version: data.version,
|
|
101
|
+
importedAt: data.importedAt,
|
|
102
|
+
profiles: plan.remainingProfiles,
|
|
103
|
+
},
|
|
104
|
+
result: plan.result,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { multiProfilesDebugLogger } from "./debug-logger.js";
|
|
2
|
+
import { loadMultiProfilesConfig, saveMultiProfilesConfig } from "./config.js";
|
|
3
|
+
import type { ProfileSortResult, ProfilesFile, SavedProfile } from "./types.js";
|
|
4
|
+
import type { ProfileSortOrder } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compare two profile names using locale-aware string comparison.
|
|
8
|
+
*/
|
|
9
|
+
function compareByName(left: SavedProfile, right: SavedProfile): number {
|
|
10
|
+
return left.name.localeCompare(right.name);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Compare two profiles by their createdAt timestamp.
|
|
15
|
+
*/
|
|
16
|
+
function compareByDate(left: SavedProfile, right: SavedProfile): number {
|
|
17
|
+
const leftDate = new Date(left.createdAt).getTime();
|
|
18
|
+
const rightDate = new Date(right.createdAt).getTime();
|
|
19
|
+
return leftDate - rightDate;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sort profiles based on the specified order.
|
|
24
|
+
* Returns a new array (immutable pattern), preserving the original data.profiles.
|
|
25
|
+
*
|
|
26
|
+
* @param data - The profiles file containing the profiles array
|
|
27
|
+
* @param order - The sort order to apply
|
|
28
|
+
* @returns ProfileSortResult with sorted profiles and applied sort order
|
|
29
|
+
*/
|
|
30
|
+
export function sortProfiles(data: ProfilesFile, order: ProfileSortOrder): ProfileSortResult {
|
|
31
|
+
const comparator = getSortComparator(order);
|
|
32
|
+
const sortedProfiles = [...data.profiles].sort(comparator);
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
sortedProfiles,
|
|
36
|
+
sortOrder: order,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Get the comparator function for a given sort order.
|
|
42
|
+
*/
|
|
43
|
+
function getSortComparator(order: ProfileSortOrder): (left: SavedProfile, right: SavedProfile) => number {
|
|
44
|
+
switch (order) {
|
|
45
|
+
case "name-asc":
|
|
46
|
+
return compareByName;
|
|
47
|
+
case "name-desc":
|
|
48
|
+
return (left, right) => compareByName(right, left);
|
|
49
|
+
case "date-asc":
|
|
50
|
+
return compareByDate;
|
|
51
|
+
case "date-desc":
|
|
52
|
+
return (left, right) => compareByDate(right, left);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the current sort order from config.
|
|
58
|
+
*/
|
|
59
|
+
export function getCurrentSortOrder(): ProfileSortOrder {
|
|
60
|
+
const result = loadMultiProfilesConfig();
|
|
61
|
+
return result.config.sorting.defaultSort;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Persist the sort order to config.
|
|
66
|
+
*/
|
|
67
|
+
export function persistSortOrder(order: ProfileSortOrder): void {
|
|
68
|
+
const result = loadMultiProfilesConfig();
|
|
69
|
+
const config = result.config;
|
|
70
|
+
config.sorting.defaultSort = order;
|
|
71
|
+
saveMultiProfilesConfig(config);
|
|
72
|
+
|
|
73
|
+
multiProfilesDebugLogger.log("config", {
|
|
74
|
+
event: "sort_order_persisted",
|
|
75
|
+
order,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get display label for a sort order.
|
|
81
|
+
*/
|
|
82
|
+
export function getSortOrderLabel(order: ProfileSortOrder): string {
|
|
83
|
+
switch (order) {
|
|
84
|
+
case "name-asc":
|
|
85
|
+
return "Name (A-Z)";
|
|
86
|
+
case "name-desc":
|
|
87
|
+
return "Name (Z-A)";
|
|
88
|
+
case "date-asc":
|
|
89
|
+
return "Date (Oldest)";
|
|
90
|
+
case "date-desc":
|
|
91
|
+
return "Date (Newest)";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get all available sort orders with their labels.
|
|
97
|
+
*/
|
|
98
|
+
export function getAvailableSortOrders(): Array<{ order: ProfileSortOrder; label: string }> {
|
|
99
|
+
return [
|
|
100
|
+
{ order: "name-asc", label: "Name (A-Z)" },
|
|
101
|
+
{ order: "name-desc", label: "Name (Z-A)" },
|
|
102
|
+
{ order: "date-asc", label: "Date (Oldest)" },
|
|
103
|
+
{ order: "date-desc", label: "Date (Newest)" },
|
|
104
|
+
];
|
|
105
|
+
}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AGENTS_DIR,
|
|
6
|
+
INITIAL_PROFILE_NAME,
|
|
7
|
+
LEGACY_PROFILE_NAME_SUFFIX,
|
|
8
|
+
PROFILE_NAME_SUFFIX,
|
|
9
|
+
PROFILE_STORE_PATH,
|
|
10
|
+
PROFILE_STORE_VERSION,
|
|
11
|
+
} from "./constants.js";
|
|
12
|
+
import { captureAgentSnapshots, type AgentSelectionOptions } from "./agent-writer.js";
|
|
13
|
+
import { writeFileAtomic } from "./atomic-write.js";
|
|
14
|
+
import { ModelProfilesError } from "./errors.js";
|
|
15
|
+
import { normalizeProfileFields, sanitizeProfileName } from "./profile-fields.js";
|
|
16
|
+
import type { ProfileStoreLoadResult, ProfilesFile, SavedProfile, SavedProfileAgent, ProfileFields } from "./types.js";
|
|
17
|
+
|
|
18
|
+
interface LegacySavedProfile {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
fields: ProfileFields;
|
|
22
|
+
sourceAgent?: string;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
return value as Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function normalizeOptionalString(value: unknown): string | undefined {
|
|
35
|
+
if (typeof value !== "string") {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const trimmed = value.trim();
|
|
39
|
+
return trimmed ? trimmed : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeTimestamp(value: unknown, fallback: string): string {
|
|
43
|
+
if (typeof value !== "string") {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
const trimmed = value.trim();
|
|
47
|
+
return trimmed ? trimmed : fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeSavedAgents(agents: readonly SavedProfileAgent[]): SavedProfileAgent[] {
|
|
51
|
+
return [...agents]
|
|
52
|
+
.map((agent) => ({
|
|
53
|
+
fileName: agent.fileName,
|
|
54
|
+
agentName: agent.agentName,
|
|
55
|
+
fields: normalizeProfileFields(agent.fields),
|
|
56
|
+
}))
|
|
57
|
+
.sort((left, right) => left.fileName.localeCompare(right.fileName) || left.agentName.localeCompare(right.agentName));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cloneSavedAgents(agents: readonly SavedProfileAgent[]): SavedProfileAgent[] {
|
|
61
|
+
return normalizeSavedAgents(agents);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeSavedAgent(raw: unknown, warnings: string[]): SavedProfileAgent | null {
|
|
65
|
+
const source = toRecord(raw);
|
|
66
|
+
const fileName = normalizeOptionalString(source.fileName) ?? normalizeOptionalString(source.sourceAgent);
|
|
67
|
+
const agentName = sanitizeProfileName(typeof source.agentName === "string" ? source.agentName : "");
|
|
68
|
+
if (!fileName || !agentName) {
|
|
69
|
+
warnings.push("Skipped one malformed saved agent snapshot entry.");
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
fileName,
|
|
75
|
+
agentName,
|
|
76
|
+
fields: normalizeProfileFields(source.fields),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function dedupeSavedAgents(agents: readonly SavedProfileAgent[], warnings: string[]): SavedProfileAgent[] {
|
|
81
|
+
const byFileName = new Map<string, SavedProfileAgent>();
|
|
82
|
+
for (const agent of agents) {
|
|
83
|
+
const key = agent.fileName.toLowerCase();
|
|
84
|
+
if (byFileName.has(key)) {
|
|
85
|
+
warnings.push(`Saved profile snapshot contained duplicate agent '${agent.fileName}'; kept the last entry.`);
|
|
86
|
+
}
|
|
87
|
+
byFileName.set(key, agent);
|
|
88
|
+
}
|
|
89
|
+
return normalizeSavedAgents([...byFileName.values()]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeSnapshotProfile(raw: unknown, warnings: string[]): SavedProfile | null {
|
|
93
|
+
const source = toRecord(raw);
|
|
94
|
+
const id = normalizeOptionalString(source.id);
|
|
95
|
+
const name = sanitizeProfileName(typeof source.name === "string" ? source.name : "");
|
|
96
|
+
if (!id || !name) {
|
|
97
|
+
warnings.push("Skipped one malformed saved profile entry.");
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!Array.isArray(source.agents)) {
|
|
102
|
+
warnings.push(`Saved profile '${name}' was missing its agent snapshot list and was skipped.`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const timestamp = new Date().toISOString();
|
|
107
|
+
const agents = dedupeSavedAgents(
|
|
108
|
+
source.agents.map((entry) => normalizeSavedAgent(entry, warnings)).filter((entry): entry is SavedProfileAgent => entry !== null),
|
|
109
|
+
warnings,
|
|
110
|
+
);
|
|
111
|
+
if (agents.length === 0) {
|
|
112
|
+
warnings.push(`Saved profile '${name}' had no valid agent snapshots and was skipped.`);
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
id,
|
|
118
|
+
name,
|
|
119
|
+
agents,
|
|
120
|
+
createdAt: normalizeTimestamp(source.createdAt, timestamp),
|
|
121
|
+
updatedAt: normalizeTimestamp(source.updatedAt, timestamp),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeLegacySavedProfile(raw: unknown, warnings: string[]): LegacySavedProfile | null {
|
|
126
|
+
const source = toRecord(raw);
|
|
127
|
+
const id = normalizeOptionalString(source.id);
|
|
128
|
+
const name = sanitizeProfileName(typeof source.name === "string" ? source.name : "");
|
|
129
|
+
if (!id || !name) {
|
|
130
|
+
warnings.push("Skipped one malformed legacy saved profile entry.");
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const timestamp = new Date().toISOString();
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
name,
|
|
138
|
+
fields: normalizeProfileFields(source.fields),
|
|
139
|
+
sourceAgent: normalizeOptionalString(source.sourceAgent),
|
|
140
|
+
createdAt: normalizeTimestamp(source.createdAt, timestamp),
|
|
141
|
+
updatedAt: normalizeTimestamp(source.updatedAt, timestamp),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function basenameWithoutMarkdown(fileName: string): string {
|
|
146
|
+
return fileName.replace(/\.md$/i, "");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function isLegacyImportedProfile(profile: LegacySavedProfile, importedAt: string | undefined): boolean {
|
|
150
|
+
if (!importedAt || !profile.sourceAgent) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (profile.createdAt !== importedAt || profile.updatedAt !== importedAt) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const expectedName = `${basenameWithoutMarkdown(profile.sourceAgent)} ${LEGACY_PROFILE_NAME_SUFFIX}`;
|
|
159
|
+
return profile.name.toLowerCase() === expectedName.toLowerCase();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildMigrationBaseline(
|
|
163
|
+
agentOptions: string | AgentSelectionOptions,
|
|
164
|
+
warnings: string[],
|
|
165
|
+
): SavedProfileAgent[] {
|
|
166
|
+
try {
|
|
167
|
+
const snapshot = captureAgentSnapshots(agentOptions);
|
|
168
|
+
warnings.push(...snapshot.warnings);
|
|
169
|
+
return cloneSavedAgents(snapshot.agents);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
172
|
+
warnings.push(`Legacy model profile migration could not read the current agents snapshot: ${message}`);
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function buildImportedSnapshotProfile(importedAt: string, baseline: readonly SavedProfileAgent[], id: string): SavedProfile | null {
|
|
178
|
+
if (baseline.length === 0) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
id,
|
|
184
|
+
name: INITIAL_PROFILE_NAME,
|
|
185
|
+
agents: cloneSavedAgents(baseline),
|
|
186
|
+
createdAt: importedAt,
|
|
187
|
+
updatedAt: importedAt,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function migrateLegacyProfile(profile: LegacySavedProfile, baseline: readonly SavedProfileAgent[], warnings: string[]): SavedProfile | null {
|
|
192
|
+
const nextAgents = cloneSavedAgents(baseline);
|
|
193
|
+
const sourceAgent = profile.sourceAgent;
|
|
194
|
+
|
|
195
|
+
if (sourceAgent) {
|
|
196
|
+
const targetIndex = nextAgents.findIndex((agent) => agent.fileName.toLowerCase() === sourceAgent.toLowerCase());
|
|
197
|
+
const agentName = nextAgents[targetIndex]?.agentName ?? basenameWithoutMarkdown(sourceAgent);
|
|
198
|
+
const migratedAgent = {
|
|
199
|
+
fileName: sourceAgent,
|
|
200
|
+
agentName,
|
|
201
|
+
fields: normalizeProfileFields(profile.fields),
|
|
202
|
+
};
|
|
203
|
+
if (targetIndex === -1) {
|
|
204
|
+
nextAgents.push(migratedAgent);
|
|
205
|
+
} else {
|
|
206
|
+
nextAgents[targetIndex] = migratedAgent;
|
|
207
|
+
}
|
|
208
|
+
} else if (nextAgents.length === 0) {
|
|
209
|
+
warnings.push(`Legacy saved profile '${profile.name}' had no source agent and could not be migrated.`);
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const agents = normalizeSavedAgents(nextAgents);
|
|
214
|
+
if (agents.length === 0) {
|
|
215
|
+
warnings.push(`Legacy saved profile '${profile.name}' could not be migrated because no agent snapshots were available.`);
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
id: profile.id,
|
|
221
|
+
name: profile.name,
|
|
222
|
+
agents,
|
|
223
|
+
createdAt: profile.createdAt,
|
|
224
|
+
updatedAt: profile.updatedAt,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function normalizeProfilesFile(
|
|
229
|
+
raw: unknown,
|
|
230
|
+
agentOptions: string | AgentSelectionOptions = AGENTS_DIR,
|
|
231
|
+
): { data: ProfilesFile; warnings: string[]; needsSave: boolean } {
|
|
232
|
+
const source = toRecord(raw);
|
|
233
|
+
const warnings: string[] = [];
|
|
234
|
+
const importedAt = normalizeOptionalString(source.importedAt);
|
|
235
|
+
const rawProfiles = Array.isArray(source.profiles) ? source.profiles : [];
|
|
236
|
+
|
|
237
|
+
if (!Array.isArray(source.profiles) && source.profiles !== undefined) {
|
|
238
|
+
warnings.push("Saved profiles file was malformed and has been reset.");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const snapshotProfiles: SavedProfile[] = [];
|
|
242
|
+
const legacyProfiles: LegacySavedProfile[] = [];
|
|
243
|
+
let needsSave = source.version !== PROFILE_STORE_VERSION;
|
|
244
|
+
|
|
245
|
+
for (const rawProfile of rawProfiles) {
|
|
246
|
+
const record = toRecord(rawProfile);
|
|
247
|
+
if (Array.isArray(record.agents)) {
|
|
248
|
+
const normalized = normalizeSnapshotProfile(record, warnings);
|
|
249
|
+
if (normalized) {
|
|
250
|
+
snapshotProfiles.push(normalized);
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
needsSave = true;
|
|
256
|
+
const legacy = normalizeLegacySavedProfile(record, warnings);
|
|
257
|
+
if (legacy) {
|
|
258
|
+
legacyProfiles.push(legacy);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (legacyProfiles.length > 0) {
|
|
263
|
+
const baseline = buildMigrationBaseline(agentOptions, warnings);
|
|
264
|
+
const importedLegacy = legacyProfiles.filter((profile) => isLegacyImportedProfile(profile, importedAt));
|
|
265
|
+
const userLegacy = legacyProfiles.filter((profile) => !isLegacyImportedProfile(profile, importedAt));
|
|
266
|
+
|
|
267
|
+
if (importedLegacy.length > 0) {
|
|
268
|
+
const importedProfile = buildImportedSnapshotProfile(importedAt ?? importedLegacy[0]?.createdAt ?? new Date().toISOString(), baseline, importedLegacy[0].id);
|
|
269
|
+
if (importedProfile) {
|
|
270
|
+
snapshotProfiles.push(importedProfile);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const legacy of userLegacy) {
|
|
275
|
+
const migrated = migrateLegacyProfile(legacy, baseline, warnings);
|
|
276
|
+
if (migrated) {
|
|
277
|
+
snapshotProfiles.push(migrated);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
warnings.push("Migrated legacy per-agent model profiles to whole-agents snapshot profiles.");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const profiles = snapshotProfiles
|
|
285
|
+
.filter((profile, index, allProfiles) => index === allProfiles.findIndex((candidate) => candidate.id === profile.id))
|
|
286
|
+
.map((profile) => ({
|
|
287
|
+
...profile,
|
|
288
|
+
agents: dedupeSavedAgents(profile.agents, warnings),
|
|
289
|
+
}))
|
|
290
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
data: {
|
|
294
|
+
version: PROFILE_STORE_VERSION,
|
|
295
|
+
importedAt,
|
|
296
|
+
profiles,
|
|
297
|
+
},
|
|
298
|
+
warnings,
|
|
299
|
+
needsSave: needsSave || warnings.length > 0,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function createEmptyProfilesFile(): ProfilesFile {
|
|
304
|
+
return {
|
|
305
|
+
version: PROFILE_STORE_VERSION,
|
|
306
|
+
profiles: [],
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function getProfileStorePath(): string {
|
|
311
|
+
return PROFILE_STORE_PATH;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function loadProfilesFile(
|
|
315
|
+
storePath = PROFILE_STORE_PATH,
|
|
316
|
+
agentOptions: string | AgentSelectionOptions = AGENTS_DIR,
|
|
317
|
+
): ProfileStoreLoadResult {
|
|
318
|
+
if (!existsSync(storePath)) {
|
|
319
|
+
return { data: createEmptyProfilesFile(), needsSave: false };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
const rawText = readFileSync(storePath, "utf-8");
|
|
324
|
+
const parsed = JSON.parse(rawText) as unknown;
|
|
325
|
+
const normalized = normalizeProfilesFile(parsed, agentOptions);
|
|
326
|
+
return {
|
|
327
|
+
data: normalized.data,
|
|
328
|
+
warning: normalized.warnings.length > 0 ? normalized.warnings.join(" ") : undefined,
|
|
329
|
+
needsSave: normalized.needsSave,
|
|
330
|
+
};
|
|
331
|
+
} catch {
|
|
332
|
+
return {
|
|
333
|
+
data: createEmptyProfilesFile(),
|
|
334
|
+
warning: `Failed to parse ${storePath}. Saved model profiles were reset in memory until the next successful save.`,
|
|
335
|
+
needsSave: false,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function saveProfilesFile(data: ProfilesFile, storePath = PROFILE_STORE_PATH): void {
|
|
341
|
+
const normalized = normalizeProfilesFile(data).data;
|
|
342
|
+
writeFileAtomic(storePath, `${JSON.stringify(normalized, null, 2)}\n`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function createProfile(name: string, agents: readonly SavedProfileAgent[], options: { timestamp?: string } = {}): SavedProfile {
|
|
346
|
+
const sanitizedName = sanitizeProfileName(name);
|
|
347
|
+
if (!sanitizedName) {
|
|
348
|
+
throw new ModelProfilesError("Profile names must not be empty.", "INVALID_PROFILE_NAME");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const normalizedAgents = normalizeSavedAgents(agents);
|
|
352
|
+
if (normalizedAgents.length === 0) {
|
|
353
|
+
throw new ModelProfilesError("Saved profiles must include at least one agent snapshot.", "INVALID_PROFILE_AGENTS");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const timestamp = options.timestamp ?? new Date().toISOString();
|
|
357
|
+
return {
|
|
358
|
+
id: randomUUID(),
|
|
359
|
+
name: sanitizedName,
|
|
360
|
+
agents: normalizedAgents,
|
|
361
|
+
createdAt: timestamp,
|
|
362
|
+
updatedAt: timestamp,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function findProfileById(data: ProfilesFile, profileId: string): SavedProfile | undefined {
|
|
367
|
+
return data.profiles.find((profile) => profile.id === profileId);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function appendProfile(data: ProfilesFile, profile: SavedProfile): ProfilesFile {
|
|
371
|
+
return {
|
|
372
|
+
...data,
|
|
373
|
+
profiles: [...data.profiles, { ...profile, agents: cloneSavedAgents(profile.agents) }],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function renameStoredProfile(data: ProfilesFile, profileId: string, nextName: string): ProfilesFile {
|
|
378
|
+
const sanitizedName = sanitizeProfileName(nextName);
|
|
379
|
+
if (!sanitizedName) {
|
|
380
|
+
throw new ModelProfilesError("Profile names must not be empty.", "INVALID_PROFILE_NAME");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let found = false;
|
|
384
|
+
const nextProfiles = data.profiles.map((profile) => {
|
|
385
|
+
if (profile.id !== profileId) {
|
|
386
|
+
return profile;
|
|
387
|
+
}
|
|
388
|
+
found = true;
|
|
389
|
+
return {
|
|
390
|
+
...profile,
|
|
391
|
+
name: sanitizedName,
|
|
392
|
+
updatedAt: new Date().toISOString(),
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (!found) {
|
|
397
|
+
throw new ModelProfilesError(`Saved profile '${profileId}' was not found.`, "PROFILE_NOT_FOUND");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
...data,
|
|
402
|
+
profiles: nextProfiles,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function resolveUniqueProfileName(baseName: string, profiles: readonly SavedProfile[]): string {
|
|
407
|
+
const sanitizedBase = sanitizeProfileName(baseName) ?? PROFILE_NAME_SUFFIX;
|
|
408
|
+
const existing = new Set(profiles.map((profile) => profile.name.toLowerCase()));
|
|
409
|
+
if (!existing.has(sanitizedBase.toLowerCase())) {
|
|
410
|
+
return sanitizedBase;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
let counter = 2;
|
|
414
|
+
while (existing.has(`${sanitizedBase} ${counter}`.toLowerCase())) {
|
|
415
|
+
counter += 1;
|
|
416
|
+
}
|
|
417
|
+
return `${sanitizedBase} ${counter}`;
|
|
418
|
+
}
|