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
package/src/config.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import type { ProfileSortOrder } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export const EXTENSION_NAME = "pi-model-profiles";
|
|
8
|
+
|
|
9
|
+
export { ProfileSortOrder as SortOrder } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export interface ProfilesConfig {
|
|
12
|
+
/** Enable automatic saving of profiles on changes (default: true) */
|
|
13
|
+
autoSave: boolean;
|
|
14
|
+
/** Maximum number of profiles to retain (default: 100, min: 1, max: 1000) */
|
|
15
|
+
maxProfiles: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SortingConfig {
|
|
19
|
+
/** Default sort order for profile list (default: 'date-desc') */
|
|
20
|
+
defaultSort: ProfileSortOrder;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MultiProfilesConfig {
|
|
24
|
+
/** Enable debug logging (default: false) */
|
|
25
|
+
debug: boolean;
|
|
26
|
+
/** Profile storage configuration */
|
|
27
|
+
profiles: ProfilesConfig;
|
|
28
|
+
/** Sorting configuration */
|
|
29
|
+
sorting: SortingConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MultiProfilesConfigLoadResult {
|
|
33
|
+
config: MultiProfilesConfig;
|
|
34
|
+
created: boolean;
|
|
35
|
+
warning?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_PROFILES_CONFIG: ProfilesConfig = {
|
|
39
|
+
autoSave: true,
|
|
40
|
+
maxProfiles: 100,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const DEFAULT_SORTING_CONFIG: SortingConfig = {
|
|
44
|
+
defaultSort: "date-desc" as ProfileSortOrder,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_MULTI_PROFILES_CONFIG: MultiProfilesConfig = {
|
|
48
|
+
debug: false,
|
|
49
|
+
profiles: { ...DEFAULT_PROFILES_CONFIG },
|
|
50
|
+
sorting: { ...DEFAULT_SORTING_CONFIG },
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function cloneProfilesConfig(config: ProfilesConfig = DEFAULT_PROFILES_CONFIG): ProfilesConfig {
|
|
54
|
+
return {
|
|
55
|
+
autoSave: config.autoSave,
|
|
56
|
+
maxProfiles: config.maxProfiles,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function cloneSortingConfig(config: SortingConfig = DEFAULT_SORTING_CONFIG): SortingConfig {
|
|
61
|
+
return {
|
|
62
|
+
defaultSort: config.defaultSort,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cloneMultiProfilesConfig(config: MultiProfilesConfig = DEFAULT_MULTI_PROFILES_CONFIG): MultiProfilesConfig {
|
|
67
|
+
return {
|
|
68
|
+
debug: config.debug,
|
|
69
|
+
profiles: cloneProfilesConfig(config.profiles),
|
|
70
|
+
sorting: cloneSortingConfig(config.sorting),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the extension root directory from a module URL.
|
|
76
|
+
*/
|
|
77
|
+
export function resolveExtensionRoot(moduleUrl: string = import.meta.url): string {
|
|
78
|
+
const __filename = fileURLToPath(moduleUrl);
|
|
79
|
+
const __dirname = dirname(__filename);
|
|
80
|
+
// Navigate up from src/ to extension root
|
|
81
|
+
return dirname(__dirname);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const EXTENSION_ROOT = resolveExtensionRoot();
|
|
85
|
+
export const CONFIG_PATH = join(EXTENSION_ROOT, "config.json");
|
|
86
|
+
export const DEBUG_DIR = join(EXTENSION_ROOT, "debug");
|
|
87
|
+
export const DEBUG_LOG_PATH = join(DEBUG_DIR, `${EXTENSION_NAME}-debug.jsonl`);
|
|
88
|
+
|
|
89
|
+
function createDefaultConfigContent(): string {
|
|
90
|
+
return JSON.stringify(DEFAULT_MULTI_PROFILES_CONFIG, null, "\t");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toRecord(value: unknown): Record<string, unknown> {
|
|
94
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
95
|
+
return value as Record<string, unknown>;
|
|
96
|
+
}
|
|
97
|
+
return {};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatValue(value: unknown): string {
|
|
101
|
+
if (typeof value === "string") {
|
|
102
|
+
return `"${value}"`;
|
|
103
|
+
}
|
|
104
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
105
|
+
return String(value);
|
|
106
|
+
}
|
|
107
|
+
if (value === null) {
|
|
108
|
+
return "null";
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
return `[${value.map(formatValue).join(", ")}]`;
|
|
112
|
+
}
|
|
113
|
+
if (typeof value === "object") {
|
|
114
|
+
return "{...}";
|
|
115
|
+
}
|
|
116
|
+
return String(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createValidationWarning(path: string, reason: string, fallback: unknown): string {
|
|
120
|
+
return `Config validation: '${path}' ${reason}. Using default: ${formatValue(fallback)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function appendWarning(warnings: string[], warning: string | undefined): void {
|
|
124
|
+
if (warning) {
|
|
125
|
+
warnings.push(warning);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readBoolean(value: unknown, path: string, fallback: boolean, warnings: string[]): boolean {
|
|
130
|
+
if (typeof value === "boolean") {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
appendWarning(warnings, createValidationWarning(path, "must be a boolean", fallback));
|
|
134
|
+
return fallback;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readNonNegativeInteger(value: unknown, path: string, fallback: number, min: number, max: number, warnings: string[]): number {
|
|
138
|
+
if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max) {
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
appendWarning(warnings, createValidationWarning(path, `must be an integer between ${min} and ${max}`, fallback));
|
|
142
|
+
return fallback;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readStringEnum<T extends string>(value: unknown, path: string, validValues: readonly T[], fallback: T, warnings: string[]): T {
|
|
146
|
+
if (typeof value === "string" && (validValues as readonly string[]).includes(value)) {
|
|
147
|
+
return value as T;
|
|
148
|
+
}
|
|
149
|
+
appendWarning(warnings, createValidationWarning(path, `must be one of: ${validValues.join(", ")}`, fallback));
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeProfilesConfig(value: unknown, warnings: string[]): ProfilesConfig {
|
|
154
|
+
const obj = toRecord(value);
|
|
155
|
+
const autoSave = readBoolean(obj.autoSave, "profiles.autoSave", DEFAULT_PROFILES_CONFIG.autoSave, warnings);
|
|
156
|
+
const maxProfiles = readNonNegativeInteger(
|
|
157
|
+
obj.maxProfiles,
|
|
158
|
+
"profiles.maxProfiles",
|
|
159
|
+
DEFAULT_PROFILES_CONFIG.maxProfiles,
|
|
160
|
+
1,
|
|
161
|
+
1000,
|
|
162
|
+
warnings,
|
|
163
|
+
);
|
|
164
|
+
return { autoSave, maxProfiles };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeSortingConfig(value: unknown, warnings: string[]): SortingConfig {
|
|
168
|
+
const obj = toRecord(value);
|
|
169
|
+
const validSortOrders: ProfileSortOrder[] = ["name-asc", "name-desc", "date-asc", "date-desc"];
|
|
170
|
+
const defaultSort = readStringEnum<ProfileSortOrder>(
|
|
171
|
+
obj.defaultSort,
|
|
172
|
+
"sorting.defaultSort",
|
|
173
|
+
validSortOrders,
|
|
174
|
+
DEFAULT_SORTING_CONFIG.defaultSort,
|
|
175
|
+
warnings,
|
|
176
|
+
);
|
|
177
|
+
return { defaultSort };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normalizeConfig(raw: unknown): { config: MultiProfilesConfig; warnings: string[] } {
|
|
181
|
+
const warnings: string[] = [];
|
|
182
|
+
const obj = toRecord(raw);
|
|
183
|
+
|
|
184
|
+
const debug = readBoolean(obj.debug, "debug", DEFAULT_MULTI_PROFILES_CONFIG.debug, warnings);
|
|
185
|
+
const profiles = normalizeProfilesConfig(obj.profiles, warnings);
|
|
186
|
+
const sorting = normalizeSortingConfig(obj.sorting, warnings);
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
config: { debug, profiles, sorting },
|
|
190
|
+
warnings,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function joinWarnings(warnings: Array<string | undefined>): string | undefined {
|
|
195
|
+
const filtered = warnings.filter((w): w is string => w !== undefined);
|
|
196
|
+
return filtered.length > 0 ? filtered.join("\n") : undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ensureConfigDirectory(configPath: string): void {
|
|
200
|
+
const configDir = dirname(configPath);
|
|
201
|
+
if (!existsSync(configDir)) {
|
|
202
|
+
mkdirSync(configDir, { recursive: true });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function ensureMultiProfilesConfig(configPath: string = CONFIG_PATH): { created: boolean; warning?: string } {
|
|
207
|
+
if (existsSync(configPath)) {
|
|
208
|
+
return { created: false };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ensureConfigDirectory(configPath);
|
|
212
|
+
writeFileSync(configPath, createDefaultConfigContent(), "utf-8");
|
|
213
|
+
return { created: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function loadMultiProfilesConfig(configPath: string = CONFIG_PATH): MultiProfilesConfigLoadResult {
|
|
217
|
+
const created = ensureMultiProfilesConfig(configPath);
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const content = readFileSync(configPath, "utf-8");
|
|
221
|
+
const raw = JSON.parse(content) as unknown;
|
|
222
|
+
const { config, warnings } = normalizeConfig(raw);
|
|
223
|
+
const warning = joinWarnings(warnings);
|
|
224
|
+
|
|
225
|
+
return { config, created: created.created, warning };
|
|
226
|
+
} catch (error) {
|
|
227
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
228
|
+
return {
|
|
229
|
+
config: cloneMultiProfilesConfig(DEFAULT_MULTI_PROFILES_CONFIG),
|
|
230
|
+
created: created.created,
|
|
231
|
+
warning: `Failed to parse config.json: ${message}. Using defaults.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function saveMultiProfilesConfig(config: MultiProfilesConfig, configPath: string = CONFIG_PATH): void {
|
|
237
|
+
ensureConfigDirectory(configPath);
|
|
238
|
+
writeFileSync(configPath, JSON.stringify(config, null, "\t"), "utf-8");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function ensureMultiProfilesDebugDirectory(debugDir: string = DEBUG_DIR): string | undefined {
|
|
242
|
+
try {
|
|
243
|
+
if (!existsSync(debugDir)) {
|
|
244
|
+
mkdirSync(debugDir, { recursive: true });
|
|
245
|
+
}
|
|
246
|
+
return debugDir;
|
|
247
|
+
} catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CONFIG_PATH, DEBUG_DIR, DEBUG_LOG_PATH, EXTENSION_ROOT } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export const EXTENSION_NAME = "pi-model-profiles";
|
|
6
|
+
export const COMMAND_NAME = "model-profiles";
|
|
7
|
+
export const PROFILE_STORE_VERSION = 2 as const;
|
|
8
|
+
export const PROFILE_NAME_SUFFIX = "snapshot";
|
|
9
|
+
export const LEGACY_PROFILE_NAME_SUFFIX = "profile";
|
|
10
|
+
export const INITIAL_PROFILE_NAME = "Current agents snapshot";
|
|
11
|
+
const AGENT_DIR = getAgentDir();
|
|
12
|
+
|
|
13
|
+
export const AGENTS_DIR = join(AGENT_DIR, "agents");
|
|
14
|
+
export const THEMES_DIR = join(AGENT_DIR, "themes");
|
|
15
|
+
export const SETTINGS_PATH = join(AGENT_DIR, "settings.json");
|
|
16
|
+
export const PROFILE_STORE_PATH = join(AGENT_DIR, "extensions", EXTENSION_NAME, "profiles.json");
|
|
17
|
+
|
|
18
|
+
// Re-export config paths for convenience
|
|
19
|
+
export { CONFIG_PATH, DEBUG_DIR, DEBUG_LOG_PATH, EXTENSION_ROOT };
|
|
20
|
+
export function resolveModalOverlayOptions(maxContentWidth: number): {
|
|
21
|
+
anchor: "center";
|
|
22
|
+
width: number;
|
|
23
|
+
maxHeight: number;
|
|
24
|
+
margin: number;
|
|
25
|
+
} {
|
|
26
|
+
const terminalWidth =
|
|
27
|
+
typeof process.stdout.columns === "number" && Number.isFinite(process.stdout.columns)
|
|
28
|
+
? process.stdout.columns
|
|
29
|
+
: MODAL_MAX_WIDTH;
|
|
30
|
+
const terminalHeight =
|
|
31
|
+
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
|
|
32
|
+
? process.stdout.rows
|
|
33
|
+
: 36;
|
|
34
|
+
const margin = 1;
|
|
35
|
+
const availableWidth = Math.max(24, terminalWidth - margin * 2);
|
|
36
|
+
const preferredWidth = calculateModalWidth(maxContentWidth);
|
|
37
|
+
const minimumWidth = Math.min(MODAL_MIN_WIDTH, availableWidth);
|
|
38
|
+
const width = Math.max(minimumWidth, Math.min(preferredWidth, availableWidth));
|
|
39
|
+
const availableHeight = Math.max(10, terminalHeight - margin * 2);
|
|
40
|
+
const preferredHeight = Math.max(10, Math.floor(terminalHeight * 0.9));
|
|
41
|
+
const maxHeight = Math.min(preferredHeight, availableHeight);
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
anchor: "center",
|
|
45
|
+
width,
|
|
46
|
+
maxHeight,
|
|
47
|
+
margin,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Minimum modal height in rows
|
|
53
|
+
*/
|
|
54
|
+
export const MODAL_MIN_HEIGHT = 20;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Base height for modal (header, footer, padding)
|
|
58
|
+
*/
|
|
59
|
+
export const MODAL_BASE_HEIGHT = 10;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Rows per agent in the details panel
|
|
63
|
+
*/
|
|
64
|
+
export const MODAL_ROWS_PER_AGENT = 3;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Calculate dynamic modal height based on agent count.
|
|
68
|
+
* Formula: base height (10) + (agentCount * 3) clamped to min 20, max 90% of terminal
|
|
69
|
+
*/
|
|
70
|
+
export function calculateModalHeight(agentCount: number): number {
|
|
71
|
+
const terminalHeight = process.stdout.rows || 24;
|
|
72
|
+
const maxAllowedHeight = Math.floor(terminalHeight * 0.9);
|
|
73
|
+
const calculatedHeight = MODAL_BASE_HEIGHT + agentCount * MODAL_ROWS_PER_AGENT;
|
|
74
|
+
return Math.max(MODAL_MIN_HEIGHT, Math.min(calculatedHeight, maxAllowedHeight));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Minimum modal width in columns
|
|
79
|
+
*/
|
|
80
|
+
export const MODAL_MIN_WIDTH = 80;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Maximum modal width in columns
|
|
84
|
+
*/
|
|
85
|
+
export const MODAL_MAX_WIDTH = 140;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Base width for modal (borders, padding, labels)
|
|
89
|
+
*/
|
|
90
|
+
export const MODAL_BASE_WIDTH = 80;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Calculate dynamic modal width based on content.
|
|
94
|
+
* Formula: base width (80) + maxAgentNameLength clamped to min 80, max 140
|
|
95
|
+
*/
|
|
96
|
+
export function calculateModalWidth(maxAgentNameLength: number): number {
|
|
97
|
+
const calculatedWidth = MODAL_BASE_WIDTH + maxAgentNameLength;
|
|
98
|
+
return Math.max(MODAL_MIN_WIDTH, Math.min(calculatedWidth, MODAL_MAX_WIDTH));
|
|
99
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { appendFile, chmod } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
CONFIG_PATH,
|
|
4
|
+
DEBUG_DIR,
|
|
5
|
+
DEBUG_LOG_PATH,
|
|
6
|
+
EXTENSION_NAME,
|
|
7
|
+
ensureMultiProfilesDebugDirectory,
|
|
8
|
+
loadMultiProfilesConfig,
|
|
9
|
+
} from "./config.js";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
|
|
12
|
+
const DEFAULT_FLUSH_ENTRY_LIMIT = 100;
|
|
13
|
+
const DEFAULT_FLUSH_BYTE_LIMIT = 50 * 1024;
|
|
14
|
+
const DEFAULT_MAX_BUFFERED_ENTRIES = 1_000;
|
|
15
|
+
const DEFAULT_MAX_BUFFERED_BYTES = 512 * 1024;
|
|
16
|
+
|
|
17
|
+
export interface MultiProfilesDebugLoggerOptions {
|
|
18
|
+
configPath?: string;
|
|
19
|
+
debugDir?: string;
|
|
20
|
+
logPath?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safeJsonStringify(value: unknown): string {
|
|
24
|
+
const seen = new WeakSet<object>();
|
|
25
|
+
return JSON.stringify(value, (_key, currentValue) => {
|
|
26
|
+
if (currentValue instanceof Error) {
|
|
27
|
+
return {
|
|
28
|
+
name: currentValue.name,
|
|
29
|
+
message: currentValue.message,
|
|
30
|
+
stack: currentValue.stack,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof currentValue === "bigint") {
|
|
35
|
+
return currentValue.toString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof currentValue === "object" && currentValue !== null) {
|
|
39
|
+
if (seen.has(currentValue)) {
|
|
40
|
+
return "[Circular]";
|
|
41
|
+
}
|
|
42
|
+
seen.add(currentValue);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return currentValue;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function normalizePositiveInteger(value: number | undefined, fallback: number): number {
|
|
50
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
|
51
|
+
return Math.floor(value);
|
|
52
|
+
}
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AsyncBufferedLogWriterOptions {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
logPath: string;
|
|
59
|
+
ensureDirectory: () => string | undefined;
|
|
60
|
+
flushIntervalMs?: number;
|
|
61
|
+
flushEntryLimit?: number;
|
|
62
|
+
flushByteLimit?: number;
|
|
63
|
+
maxBufferedEntries?: number;
|
|
64
|
+
maxBufferedBytes?: number;
|
|
65
|
+
createDroppedEntriesLine?: (droppedEntries: number) => string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class AsyncBufferedLogWriter {
|
|
69
|
+
private readonly flushIntervalMs: number;
|
|
70
|
+
private readonly flushEntryLimit: number;
|
|
71
|
+
private readonly flushByteLimit: number;
|
|
72
|
+
private readonly maxBufferedEntries: number;
|
|
73
|
+
private readonly maxBufferedBytes: number;
|
|
74
|
+
private readonly createDroppedEntriesLine?: (droppedEntries: number) => string;
|
|
75
|
+
private readonly lines: string[] = [];
|
|
76
|
+
private enabled: boolean;
|
|
77
|
+
private bufferedBytes = 0;
|
|
78
|
+
private droppedEntries = 0;
|
|
79
|
+
private directoryReady = false;
|
|
80
|
+
private initializationError: string | undefined;
|
|
81
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
82
|
+
private flushPromise: Promise<void> | null = null;
|
|
83
|
+
private flushRequestedWhileBusy = false;
|
|
84
|
+
private shutdownHooksRegistered = false;
|
|
85
|
+
|
|
86
|
+
constructor(private readonly options: AsyncBufferedLogWriterOptions) {
|
|
87
|
+
this.enabled = options.enabled;
|
|
88
|
+
this.flushIntervalMs = normalizePositiveInteger(
|
|
89
|
+
options.flushIntervalMs,
|
|
90
|
+
DEFAULT_FLUSH_INTERVAL_MS,
|
|
91
|
+
);
|
|
92
|
+
this.flushEntryLimit = normalizePositiveInteger(
|
|
93
|
+
options.flushEntryLimit,
|
|
94
|
+
DEFAULT_FLUSH_ENTRY_LIMIT,
|
|
95
|
+
);
|
|
96
|
+
this.flushByteLimit = normalizePositiveInteger(
|
|
97
|
+
options.flushByteLimit,
|
|
98
|
+
DEFAULT_FLUSH_BYTE_LIMIT,
|
|
99
|
+
);
|
|
100
|
+
this.maxBufferedEntries = Math.max(
|
|
101
|
+
this.flushEntryLimit,
|
|
102
|
+
normalizePositiveInteger(options.maxBufferedEntries, DEFAULT_MAX_BUFFERED_ENTRIES),
|
|
103
|
+
);
|
|
104
|
+
this.maxBufferedBytes = Math.max(
|
|
105
|
+
this.flushByteLimit,
|
|
106
|
+
normalizePositiveInteger(options.maxBufferedBytes, DEFAULT_MAX_BUFFERED_BYTES),
|
|
107
|
+
);
|
|
108
|
+
this.createDroppedEntriesLine = options.createDroppedEntriesLine;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setEnabled(enabled: boolean): void {
|
|
112
|
+
if (this.enabled === enabled) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
this.enabled = enabled;
|
|
117
|
+
if (!enabled) {
|
|
118
|
+
this.clearBuffer();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
writeLine(line: string): string | undefined {
|
|
123
|
+
if (!this.enabled) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const directoryError = this.ensureReady();
|
|
128
|
+
if (directoryError) {
|
|
129
|
+
return directoryError;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
this.registerShutdownHooks();
|
|
133
|
+
this.pushLine(line);
|
|
134
|
+
if (
|
|
135
|
+
this.lines.length >= this.flushEntryLimit ||
|
|
136
|
+
this.bufferedBytes >= this.flushByteLimit
|
|
137
|
+
) {
|
|
138
|
+
void this.flush();
|
|
139
|
+
} else {
|
|
140
|
+
this.scheduleFlush();
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async flush(): Promise<void> {
|
|
146
|
+
if (!this.enabled || this.lines.length === 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (this.flushPromise) {
|
|
151
|
+
this.flushRequestedWhileBusy = true;
|
|
152
|
+
await this.flushPromise;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.clearFlushTimer();
|
|
157
|
+
|
|
158
|
+
const linesToFlush = [...this.lines];
|
|
159
|
+
const droppedEntries = this.droppedEntries;
|
|
160
|
+
this.clearBuffer();
|
|
161
|
+
|
|
162
|
+
this.flushPromise = this.performFlush(linesToFlush, droppedEntries);
|
|
163
|
+
try {
|
|
164
|
+
await this.flushPromise;
|
|
165
|
+
} finally {
|
|
166
|
+
this.flushPromise = null;
|
|
167
|
+
if (this.flushRequestedWhileBusy) {
|
|
168
|
+
this.flushRequestedWhileBusy = false;
|
|
169
|
+
void this.flush();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private ensureReady(): string | undefined {
|
|
175
|
+
if (this.directoryReady) {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (this.initializationError) {
|
|
180
|
+
return this.initializationError;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const result = this.options.ensureDirectory();
|
|
185
|
+
if (!result) {
|
|
186
|
+
this.initializationError = "Debug directory could not be created or accessed.";
|
|
187
|
+
return this.initializationError;
|
|
188
|
+
}
|
|
189
|
+
this.directoryReady = true;
|
|
190
|
+
return undefined;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
this.initializationError =
|
|
193
|
+
error instanceof Error ? error.message : "Failed to initialize debug directory.";
|
|
194
|
+
return this.initializationError;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private pushLine(line: string): void {
|
|
199
|
+
const lineBytes = Buffer.byteLength(line, "utf-8");
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
this.lines.length >= this.maxBufferedEntries ||
|
|
203
|
+
this.bufferedBytes + lineBytes > this.maxBufferedBytes
|
|
204
|
+
) {
|
|
205
|
+
this.droppedEntries++;
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
this.lines.push(line);
|
|
210
|
+
this.bufferedBytes += lineBytes;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private clearBuffer(): void {
|
|
214
|
+
this.lines.length = 0;
|
|
215
|
+
this.bufferedBytes = 0;
|
|
216
|
+
this.droppedEntries = 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private scheduleFlush(): void {
|
|
220
|
+
if (this.flushTimer) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.flushTimer = setTimeout(() => {
|
|
225
|
+
this.flushTimer = null;
|
|
226
|
+
void this.flush();
|
|
227
|
+
}, this.flushIntervalMs);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private clearFlushTimer(): void {
|
|
231
|
+
if (this.flushTimer) {
|
|
232
|
+
clearTimeout(this.flushTimer);
|
|
233
|
+
this.flushTimer = null;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async performFlush(lines: string[], droppedEntries: number): Promise<void> {
|
|
238
|
+
let content = lines.join("");
|
|
239
|
+
|
|
240
|
+
if (droppedEntries > 0 && this.createDroppedEntriesLine) {
|
|
241
|
+
const droppedLine = this.createDroppedEntriesLine(droppedEntries);
|
|
242
|
+
content = droppedLine + content;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
await appendFile(this.options.logPath, content, "utf-8");
|
|
247
|
+
if (process.platform !== "win32") {
|
|
248
|
+
await chmod(this.options.logPath, 0o600);
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
this.initializationError =
|
|
252
|
+
error instanceof Error ? error.message : "Failed to write debug log.";
|
|
253
|
+
this.directoryReady = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private registerShutdownHooks(): void {
|
|
258
|
+
if (this.shutdownHooksRegistered) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
this.shutdownHooksRegistered = true;
|
|
263
|
+
|
|
264
|
+
const flushAndExit = async (signal: string): Promise<void> => {
|
|
265
|
+
try {
|
|
266
|
+
await this.flush();
|
|
267
|
+
} finally {
|
|
268
|
+
process.removeListener(signal, flushAndExit as never);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
process.on("exit", () => {
|
|
273
|
+
try {
|
|
274
|
+
const flushResult = this.flush();
|
|
275
|
+
if (flushResult && typeof flushResult.then === "function") {
|
|
276
|
+
flushResult.catch(() => {
|
|
277
|
+
// Ignore flush errors on exit
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Ignore sync flush errors on exit
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
process.on("beforeExit", () => {
|
|
286
|
+
void this.flush();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
process.on("SIGINT", () => {
|
|
290
|
+
void flushAndExit("SIGINT");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
process.on("SIGTERM", () => {
|
|
294
|
+
void flushAndExit("SIGTERM");
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export class MultiProfilesDebugLogger {
|
|
300
|
+
private initialized = false;
|
|
301
|
+
private readonly writer: AsyncBufferedLogWriter;
|
|
302
|
+
|
|
303
|
+
constructor(private readonly options: MultiProfilesDebugLoggerOptions = {}) {
|
|
304
|
+
this.writer = new AsyncBufferedLogWriter({
|
|
305
|
+
enabled: false,
|
|
306
|
+
logPath: this.options.logPath ?? DEBUG_LOG_PATH,
|
|
307
|
+
ensureDirectory: () => ensureMultiProfilesDebugDirectory(this.options.debugDir ?? DEBUG_DIR),
|
|
308
|
+
createDroppedEntriesLine: (droppedEntries) =>
|
|
309
|
+
`${safeJsonStringify({
|
|
310
|
+
timestamp: new Date().toISOString(),
|
|
311
|
+
level: "warn",
|
|
312
|
+
extension: EXTENSION_NAME,
|
|
313
|
+
event: "debug_log_overflow",
|
|
314
|
+
droppedEntries,
|
|
315
|
+
})}\n`,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private initialize(): void {
|
|
320
|
+
if (this.initialized) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
this.initialized = true;
|
|
325
|
+
const configResult = loadMultiProfilesConfig(this.options.configPath ?? CONFIG_PATH);
|
|
326
|
+
this.writer.setEnabled(configResult.config.debug);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
log(event: string, payload: Record<string, unknown> = {}): void {
|
|
330
|
+
try {
|
|
331
|
+
this.initialize();
|
|
332
|
+
this.writer.writeLine(
|
|
333
|
+
`${safeJsonStringify({
|
|
334
|
+
timestamp: new Date().toISOString(),
|
|
335
|
+
level: "debug",
|
|
336
|
+
extension: EXTENSION_NAME,
|
|
337
|
+
event,
|
|
338
|
+
...payload,
|
|
339
|
+
})}\n`,
|
|
340
|
+
);
|
|
341
|
+
} catch {
|
|
342
|
+
// Debug log failures must never affect extension functionality.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
flush(): Promise<void> {
|
|
347
|
+
return this.writer.flush();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export const multiProfilesDebugLogger = new MultiProfilesDebugLogger();
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export class ModelProfilesError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
|
|
4
|
+
constructor(message: string, code = "MODEL_PROFILES_ERROR") {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ModelProfilesError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function toErrorMessage(error: unknown): string {
|
|
12
|
+
if (error instanceof Error && error.message.trim()) {
|
|
13
|
+
return error.message;
|
|
14
|
+
}
|
|
15
|
+
return String(error);
|
|
16
|
+
}
|