@aeondave/pi-persona 1.16.3 → 1.17.1
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/AGENTS.md +22 -3
- package/CHANGELOG.md +56 -0
- package/README.md +18 -2
- package/docs/ARCHITECTURE.md +58 -7
- package/docs/REFERENCE.md +125 -3
- package/docs/TELEMETRY.md +8 -0
- package/package.json +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +251 -0
- package/src/engine/adapter.ts +1 -1
- package/src/engine/async.ts +53 -13
- package/src/engine/child.ts +1 -1
- package/src/engine/errors.ts +1 -0
- package/src/engine/inproc.ts +3 -3
- package/src/engine/stream.ts +20 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/hooks.ts +47 -12
- package/src/extension/persona-model-settings.ts +318 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension.ts +257 -50
- package/src/orchestration/sdk.ts +2 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/controller.ts +16 -3
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +7 -5
- package/src/tools/delegate-tool.ts +6 -6
- package/src/tools/delegate.ts +3 -1
- package/src/tools/exocom.ts +14 -1
- package/src/ui/persona-settings.ts +322 -0
- package/src/persona/state.ts +0 -28
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
/** Canonical versioned settings store for global preferences, persona models, and last selection. */
|
|
2
|
+
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { TextDecoder } from "node:util";
|
|
5
|
+
import {
|
|
6
|
+
closeSync,
|
|
7
|
+
constants,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
fsyncSync,
|
|
10
|
+
fstatSync,
|
|
11
|
+
linkSync,
|
|
12
|
+
lstatSync,
|
|
13
|
+
mkdirSync,
|
|
14
|
+
openSync,
|
|
15
|
+
readSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeSync,
|
|
19
|
+
type BigIntStats,
|
|
20
|
+
} from "node:fs";
|
|
21
|
+
import { dirname } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { PERSONA_SETTINGS, validatePreferences, type PersonaPreferences, type SettingId } from "../core/settings.ts";
|
|
24
|
+
import type { PersonaConfigStore } from "./config-store.ts";
|
|
25
|
+
|
|
26
|
+
export interface PersonaSettings {
|
|
27
|
+
version: 1;
|
|
28
|
+
preferences: PersonaPreferences;
|
|
29
|
+
personas: PersonaConfigStore;
|
|
30
|
+
lastPersona: string | null;
|
|
31
|
+
/** Explicit lastPersona patch count; absent in older v1 files means zero. */
|
|
32
|
+
selectionRevision?: number;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PersonaSettingsPatch {
|
|
37
|
+
preferences?: Partial<Record<SettingId, unknown | null>>;
|
|
38
|
+
models?: Record<string, Record<string, string | null>>;
|
|
39
|
+
lastPersona?: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PersonaSettingsWriteIO {
|
|
43
|
+
rename?: (from: string, to: string) => void;
|
|
44
|
+
sleep?: (ms: number) => void;
|
|
45
|
+
/** Exclusive lock-file open seam, useful for exercising transient platform errors. */
|
|
46
|
+
openExclusiveLock?: (file: string) => number;
|
|
47
|
+
acquireLock?: (file: string, sleep: (ms: number) => void) => () => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PersonaSettingsMigrationOptions {
|
|
51
|
+
legacyConfigFile: string;
|
|
52
|
+
legacyStateFile: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PersonaSettingsMigrationResult {
|
|
56
|
+
settings: PersonaSettings;
|
|
57
|
+
/** Legacy source paths successfully archived as `.migrated` files. */
|
|
58
|
+
migrated: string[];
|
|
59
|
+
warnings: string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MAX_SETTINGS_BYTES = 1024 * 1024;
|
|
63
|
+
const RENAME_ATTEMPTS = 4;
|
|
64
|
+
const RENAME_BACKOFF_MS = 25;
|
|
65
|
+
const LOCK_ATTEMPTS = 80;
|
|
66
|
+
const LOCK_BACKOFF_MS = 25;
|
|
67
|
+
const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
68
|
+
const NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
69
|
+
|
|
70
|
+
interface FileSnapshot {
|
|
71
|
+
file: string;
|
|
72
|
+
bytes: Buffer;
|
|
73
|
+
dev: bigint;
|
|
74
|
+
ino: bigint;
|
|
75
|
+
size: bigint;
|
|
76
|
+
mtimeNs: bigint;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
80
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
81
|
+
const prototype = Object.getPrototypeOf(value) as unknown;
|
|
82
|
+
return prototype === Object.prototype || prototype === null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function decodeJson(bytes: Buffer): string {
|
|
86
|
+
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
87
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function errorCode(error: unknown): string | undefined {
|
|
91
|
+
return (error as NodeJS.ErrnoException | undefined)?.code;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defineOwn(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
95
|
+
Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function emptySettings(): PersonaSettings {
|
|
99
|
+
return { version: 1, preferences: {}, personas: {}, lastPersona: null, selectionRevision: 0 };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertRegularSingleLink(file: string, stats: BigIntStats, maxLinks = 1n): void {
|
|
103
|
+
if (!stats.isFile() || stats.isSymbolicLink() || stats.nlink < 1n || stats.nlink > maxLinks) {
|
|
104
|
+
throw new Error(`unsafe settings file (expected a regular single-link file): ${file}`);
|
|
105
|
+
}
|
|
106
|
+
if (stats.size > BigInt(MAX_SETTINGS_BYTES)) {
|
|
107
|
+
throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes: ${file}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function sameIdentity(left: BigIntStats, right: BigIntStats): boolean {
|
|
112
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readBoundedFile(file: string, maxLinks = 1n): FileSnapshot | undefined {
|
|
116
|
+
let pathBefore: BigIntStats;
|
|
117
|
+
try {
|
|
118
|
+
pathBefore = lstatSync(file, { bigint: true });
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (errorCode(error) === "ENOENT") return undefined;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
assertRegularSingleLink(file, pathBefore, maxLinks);
|
|
124
|
+
|
|
125
|
+
const fd = openSync(file, constants.O_RDONLY | NOFOLLOW);
|
|
126
|
+
try {
|
|
127
|
+
const before = fstatSync(fd, { bigint: true });
|
|
128
|
+
assertRegularSingleLink(file, before, maxLinks);
|
|
129
|
+
if (!sameIdentity(pathBefore, before)) throw new Error(`settings file changed while opening: ${file}`);
|
|
130
|
+
|
|
131
|
+
const capacity = MAX_SETTINGS_BYTES + 1;
|
|
132
|
+
const buffer = Buffer.allocUnsafe(capacity);
|
|
133
|
+
let offset = 0;
|
|
134
|
+
while (offset < capacity) {
|
|
135
|
+
const count = readSync(fd, buffer, offset, capacity - offset, offset);
|
|
136
|
+
if (count === 0) break;
|
|
137
|
+
offset += count;
|
|
138
|
+
}
|
|
139
|
+
if (offset > MAX_SETTINGS_BYTES) throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes: ${file}`);
|
|
140
|
+
|
|
141
|
+
const after = fstatSync(fd, { bigint: true });
|
|
142
|
+
const pathAfter = lstatSync(file, { bigint: true });
|
|
143
|
+
assertRegularSingleLink(file, after, maxLinks);
|
|
144
|
+
if (!sameIdentity(before, after) || !sameIdentity(before, pathAfter)
|
|
145
|
+
|| before.size !== after.size || before.mtimeNs !== after.mtimeNs || BigInt(offset) !== after.size) {
|
|
146
|
+
throw new Error(`settings file changed while reading: ${file}`);
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
file,
|
|
150
|
+
bytes: Buffer.from(buffer.subarray(0, offset)),
|
|
151
|
+
dev: after.dev,
|
|
152
|
+
ino: after.ino,
|
|
153
|
+
size: after.size,
|
|
154
|
+
mtimeNs: after.mtimeNs,
|
|
155
|
+
};
|
|
156
|
+
} finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function sameSnapshot(left: FileSnapshot, right: FileSnapshot): boolean {
|
|
162
|
+
return left.dev === right.dev && left.ino === right.ino && left.size === right.size
|
|
163
|
+
&& left.mtimeNs === right.mtimeNs && left.bytes.equals(right.bytes);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function validatePersonas(value: unknown): PersonaConfigStore {
|
|
167
|
+
if (!isPlainObject(value)) throw new Error("invalid persona settings: personas must be an object");
|
|
168
|
+
const personas: Record<string, unknown> = {};
|
|
169
|
+
for (const [name, raw] of Object.entries(value)) {
|
|
170
|
+
if (!isPlainObject(raw)) throw new Error(`invalid persona settings: persona ${name} must be an object`);
|
|
171
|
+
const config: Record<string, unknown> = { ...raw };
|
|
172
|
+
if (Object.hasOwn(raw, "models")) {
|
|
173
|
+
if (!isPlainObject(raw.models)) throw new Error(`invalid persona settings: models for ${name} must be an object`);
|
|
174
|
+
const models: Record<string, unknown> = {};
|
|
175
|
+
for (const [agent, model] of Object.entries(raw.models)) {
|
|
176
|
+
if (typeof model !== "string") throw new Error(`invalid persona settings: model for ${name}/${agent} must be a string`);
|
|
177
|
+
defineOwn(models, agent, model);
|
|
178
|
+
}
|
|
179
|
+
config.models = models;
|
|
180
|
+
}
|
|
181
|
+
defineOwn(personas, name, config);
|
|
182
|
+
}
|
|
183
|
+
return personas as unknown as PersonaConfigStore;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseSettings(bytes: Buffer, file: string): PersonaSettings {
|
|
187
|
+
let parsed: unknown;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(decodeJson(bytes)) as unknown;
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error(`invalid JSON in persona settings file: ${file}`);
|
|
192
|
+
}
|
|
193
|
+
if (!isPlainObject(parsed)) throw new Error(`invalid persona settings file (expected an object): ${file}`);
|
|
194
|
+
if (parsed.version !== 1) throw new Error(`unsupported persona settings version in ${file}`);
|
|
195
|
+
if (!Object.hasOwn(parsed, "preferences")) throw new Error(`invalid persona settings: missing preferences in ${file}`);
|
|
196
|
+
if (!Object.hasOwn(parsed, "personas")) throw new Error(`invalid persona settings: missing personas in ${file}`);
|
|
197
|
+
if (!Object.hasOwn(parsed, "lastPersona")) throw new Error(`invalid persona settings: missing lastPersona in ${file}`);
|
|
198
|
+
const preferences = validatePreferences(parsed.preferences);
|
|
199
|
+
const personas = validatePersonas(parsed.personas);
|
|
200
|
+
const lastPersona = parsed.lastPersona;
|
|
201
|
+
if (lastPersona !== null && (typeof lastPersona !== "string" || lastPersona.length === 0)) {
|
|
202
|
+
throw new Error(`invalid persona settings: lastPersona must be a non-empty string or null in ${file}`);
|
|
203
|
+
}
|
|
204
|
+
const selectionRevision = Object.hasOwn(parsed, "selectionRevision") ? parsed.selectionRevision : 0;
|
|
205
|
+
if (typeof selectionRevision !== "number" || !Number.isSafeInteger(selectionRevision) || selectionRevision < 0) {
|
|
206
|
+
throw new Error(`invalid persona settings: selectionRevision must be a non-negative safe integer in ${file}`);
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
...parsed,
|
|
210
|
+
version: 1,
|
|
211
|
+
preferences,
|
|
212
|
+
personas,
|
|
213
|
+
lastPersona,
|
|
214
|
+
selectionRevision,
|
|
215
|
+
} as PersonaSettings;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function readExistingSettings(file: string): PersonaSettings | undefined {
|
|
219
|
+
const snapshot = readBoundedFile(file);
|
|
220
|
+
return snapshot ? parseSettings(snapshot.bytes, file) : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Read the canonical file. Missing means defaults; every other invalid or unsafe state throws. */
|
|
224
|
+
export function readPersonaSettings(file: string): PersonaSettings {
|
|
225
|
+
return readExistingSettings(file) ?? emptySettings();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function sleepSync(ms: number): void {
|
|
229
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function removeOwnedLock(lock: string, fd: number, identity: BigIntStats): void {
|
|
233
|
+
try {
|
|
234
|
+
closeSync(fd);
|
|
235
|
+
} finally {
|
|
236
|
+
try {
|
|
237
|
+
const current = lstatSync(lock, { bigint: true });
|
|
238
|
+
if (current.isFile() && !current.isSymbolicLink() && current.nlink === 1n && sameIdentity(identity, current)) unlinkSync(lock);
|
|
239
|
+
} catch {
|
|
240
|
+
/* A replaced or externally removed lock is not ours to remove. */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function acquireFileLock(
|
|
246
|
+
file: string,
|
|
247
|
+
sleep: (ms: number) => void,
|
|
248
|
+
openExclusiveLock: (file: string) => number = (lock) => openSync(lock, "wx", 0o600),
|
|
249
|
+
): () => void {
|
|
250
|
+
const lock = `${file}.lock`;
|
|
251
|
+
let lastOpenError: string | undefined;
|
|
252
|
+
for (let attempt = 1; attempt <= LOCK_ATTEMPTS; attempt++) {
|
|
253
|
+
let fd: number;
|
|
254
|
+
try {
|
|
255
|
+
fd = openExclusiveLock(lock);
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const code = errorCode(error);
|
|
258
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
|
|
259
|
+
lastOpenError = code;
|
|
260
|
+
if (code === "EEXIST") {
|
|
261
|
+
try {
|
|
262
|
+
const current = lstatSync(lock, { bigint: true });
|
|
263
|
+
if (current.isFile() && !current.isSymbolicLink() && current.nlink === 1n
|
|
264
|
+
&& Date.now() - Number(current.mtimeMs) > STALE_LOCK_MS) {
|
|
265
|
+
const confirmed = lstatSync(lock, { bigint: true });
|
|
266
|
+
if (sameIdentity(current, confirmed) && confirmed.isFile() && confirmed.nlink === 1n) unlinkSync(lock);
|
|
267
|
+
}
|
|
268
|
+
} catch {
|
|
269
|
+
/* Lock disappeared or is unsafe; retain it and keep the wait bounded. */
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (attempt < LOCK_ATTEMPTS) {
|
|
273
|
+
sleep(LOCK_BACKOFF_MS);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`timed out acquiring persona settings lock: ${lock} (last open error: ${lastOpenError})`);
|
|
277
|
+
}
|
|
278
|
+
const identity = fstatSync(fd, { bigint: true });
|
|
279
|
+
try {
|
|
280
|
+
writeSync(fd, `${process.pid}\n${randomBytes(8).toString("hex")}\n`, null, "utf8");
|
|
281
|
+
} catch (error) {
|
|
282
|
+
removeOwnedLock(lock, fd, identity);
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
return () => removeOwnedLock(lock, fd, identity);
|
|
286
|
+
}
|
|
287
|
+
throw new Error(`timed out acquiring persona settings lock: ${lock}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function normalizePatch(value: unknown): {
|
|
291
|
+
preferences?: Partial<Record<SettingId, unknown | null>>;
|
|
292
|
+
models?: Record<string, Record<string, string | null>>;
|
|
293
|
+
lastPersonaPresent: boolean;
|
|
294
|
+
lastPersona?: string | null;
|
|
295
|
+
} {
|
|
296
|
+
if (!isPlainObject(value)) throw new Error("invalid persona settings patch: expected an object");
|
|
297
|
+
for (const key of Object.keys(value)) {
|
|
298
|
+
if (key !== "preferences" && key !== "models" && key !== "lastPersona") {
|
|
299
|
+
throw new Error(`unknown persona settings patch field: ${key}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const result: {
|
|
303
|
+
preferences?: Partial<Record<SettingId, unknown | null>>;
|
|
304
|
+
models?: Record<string, Record<string, string | null>>;
|
|
305
|
+
lastPersonaPresent: boolean;
|
|
306
|
+
lastPersona?: string | null;
|
|
307
|
+
} = { lastPersonaPresent: Object.hasOwn(value, "lastPersona") };
|
|
308
|
+
if (Object.hasOwn(value, "preferences")) {
|
|
309
|
+
if (!isPlainObject(value.preferences)) throw new Error("invalid persona settings patch: preferences must be an object");
|
|
310
|
+
const preferences: Partial<Record<SettingId, unknown | null>> = {};
|
|
311
|
+
for (const [key, item] of Object.entries(value.preferences)) {
|
|
312
|
+
if (item === null) {
|
|
313
|
+
if (!isSettingId(key)) throw new Error(`unknown persona preference: ${key}`);
|
|
314
|
+
Object.defineProperty(preferences, key, { value: null, enumerable: true, configurable: true, writable: true });
|
|
315
|
+
} else {
|
|
316
|
+
const validated = validatePreferences({ [key]: item });
|
|
317
|
+
Object.defineProperty(preferences, key, { value: validated[key as SettingId], enumerable: true, configurable: true, writable: true });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
result.preferences = preferences;
|
|
321
|
+
}
|
|
322
|
+
if (Object.hasOwn(value, "models")) {
|
|
323
|
+
if (!isPlainObject(value.models)) throw new Error("invalid persona settings patch: models must be an object");
|
|
324
|
+
const models: Record<string, Record<string, string | null>> = {};
|
|
325
|
+
for (const [persona, rawModels] of Object.entries(value.models)) {
|
|
326
|
+
if (!isPlainObject(rawModels)) throw new Error(`invalid model patch for ${persona}: expected an object`);
|
|
327
|
+
const entries: Record<string, string | null> = {};
|
|
328
|
+
for (const [agent, model] of Object.entries(rawModels)) {
|
|
329
|
+
if (model !== null && typeof model !== "string") throw new Error(`invalid model patch for ${persona}/${agent}: expected a string or null`);
|
|
330
|
+
defineOwn(entries, agent, model);
|
|
331
|
+
}
|
|
332
|
+
defineOwn(models, persona, entries);
|
|
333
|
+
}
|
|
334
|
+
result.models = models;
|
|
335
|
+
}
|
|
336
|
+
if (result.lastPersonaPresent) {
|
|
337
|
+
const lastPersona = value.lastPersona;
|
|
338
|
+
if (lastPersona !== null && (typeof lastPersona !== "string" || lastPersona.length === 0)) {
|
|
339
|
+
throw new Error("invalid lastPersona patch: expected a non-empty string or null");
|
|
340
|
+
}
|
|
341
|
+
result.lastPersona = lastPersona;
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function isSettingId(value: string): value is SettingId {
|
|
347
|
+
return PERSONA_SETTINGS.some((setting) => setting.id === value);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function cloneSettings(settings: PersonaSettings): PersonaSettings {
|
|
351
|
+
return {
|
|
352
|
+
...settings,
|
|
353
|
+
preferences: validatePreferences(settings.preferences),
|
|
354
|
+
personas: { ...settings.personas },
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function applyPatch(settings: PersonaSettings, patch: ReturnType<typeof normalizePatch>): PersonaSettings {
|
|
359
|
+
const next = cloneSettings(settings);
|
|
360
|
+
if (patch.preferences) {
|
|
361
|
+
const preferences = next.preferences as Record<string, unknown>;
|
|
362
|
+
for (const [key, value] of Object.entries(patch.preferences)) {
|
|
363
|
+
if (value === null) delete preferences[key];
|
|
364
|
+
else defineOwn(preferences, key, value);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (patch.models) {
|
|
368
|
+
const personas = next.personas as unknown as Record<string, unknown>;
|
|
369
|
+
for (const [persona, changes] of Object.entries(patch.models)) {
|
|
370
|
+
const oldConfig = Object.hasOwn(personas, persona) && isPlainObject(personas[persona])
|
|
371
|
+
? personas[persona] as Record<string, unknown>
|
|
372
|
+
: {};
|
|
373
|
+
const config: Record<string, unknown> = { ...oldConfig };
|
|
374
|
+
const models: Record<string, unknown> = isPlainObject(oldConfig.models) ? { ...oldConfig.models } : {};
|
|
375
|
+
for (const [agent, model] of Object.entries(changes)) {
|
|
376
|
+
if (model === null) delete models[agent];
|
|
377
|
+
else defineOwn(models, agent, model);
|
|
378
|
+
}
|
|
379
|
+
if (Object.keys(models).length > 0) config.models = models;
|
|
380
|
+
else delete config.models;
|
|
381
|
+
defineOwn(personas, persona, config);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (patch.lastPersonaPresent) {
|
|
385
|
+
const revision = next.selectionRevision ?? 0;
|
|
386
|
+
if (revision >= Number.MAX_SAFE_INTEGER) {
|
|
387
|
+
throw new Error("invalid persona settings: selectionRevision cannot be incremented beyond the safe integer range");
|
|
388
|
+
}
|
|
389
|
+
next.lastPersona = patch.lastPersona ?? null;
|
|
390
|
+
next.selectionRevision = revision + 1;
|
|
391
|
+
}
|
|
392
|
+
return next;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function writeAll(fd: number, bytes: Buffer): void {
|
|
396
|
+
let offset = 0;
|
|
397
|
+
while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function removeTemp(file: string): void {
|
|
401
|
+
try {
|
|
402
|
+
unlinkSync(file);
|
|
403
|
+
} catch {
|
|
404
|
+
/* The temp may already have been renamed or removed by cleanup. */
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function removeLinkIfIdentity(file: string, snapshot: FileSnapshot): void {
|
|
409
|
+
try {
|
|
410
|
+
const current = lstatSync(file, { bigint: true });
|
|
411
|
+
if (current.isFile() && !current.isSymbolicLink() && current.dev === snapshot.dev && current.ino === snapshot.ino) {
|
|
412
|
+
unlinkSync(file);
|
|
413
|
+
}
|
|
414
|
+
} catch {
|
|
415
|
+
/* A changed or already removed archive path is not ours to remove. */
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function atomicWrite(file: string, payload: string, io: PersonaSettingsWriteIO, expectMissing: boolean): void {
|
|
420
|
+
const contents = Buffer.from(payload, "utf8");
|
|
421
|
+
if (contents.byteLength > MAX_SETTINGS_BYTES) {
|
|
422
|
+
throw new Error(`persona settings update exceeds ${MAX_SETTINGS_BYTES} bytes`);
|
|
423
|
+
}
|
|
424
|
+
const temporary = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
425
|
+
let fd: number | undefined;
|
|
426
|
+
try {
|
|
427
|
+
fd = openSync(temporary, "wx", 0o600);
|
|
428
|
+
writeAll(fd, contents);
|
|
429
|
+
fsyncSync(fd);
|
|
430
|
+
closeSync(fd);
|
|
431
|
+
fd = undefined;
|
|
432
|
+
if (expectMissing) {
|
|
433
|
+
// Exclusive same-volume link makes first creation atomic without replacing a racing file.
|
|
434
|
+
linkSync(temporary, file);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const rename = io.rename ?? renameSync;
|
|
438
|
+
const sleep = io.sleep ?? sleepSync;
|
|
439
|
+
for (let attempt = 1; ; attempt++) {
|
|
440
|
+
try {
|
|
441
|
+
rename(temporary, file);
|
|
442
|
+
return;
|
|
443
|
+
} catch (error) {
|
|
444
|
+
const code = errorCode(error);
|
|
445
|
+
if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
|
|
446
|
+
sleep(RENAME_BACKOFF_MS * attempt);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
} finally {
|
|
453
|
+
if (fd !== undefined) {
|
|
454
|
+
try { closeSync(fd); } catch { /* Preserve the write failure. */ }
|
|
455
|
+
}
|
|
456
|
+
removeTemp(temporary);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Patch known fields under one cross-process lock and atomically replace the canonical file. */
|
|
461
|
+
export function patchPersonaSettings(
|
|
462
|
+
file: string,
|
|
463
|
+
patchInput: PersonaSettingsPatch,
|
|
464
|
+
io: PersonaSettingsWriteIO = {},
|
|
465
|
+
): PersonaSettings {
|
|
466
|
+
const patch = normalizePatch(patchInput);
|
|
467
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
468
|
+
const sleep = io.sleep ?? sleepSync;
|
|
469
|
+
const release = io.acquireLock
|
|
470
|
+
? io.acquireLock(file, sleep)
|
|
471
|
+
: acquireFileLock(file, sleep, io.openExclusiveLock);
|
|
472
|
+
try {
|
|
473
|
+
const existing = readExistingSettings(file);
|
|
474
|
+
const current = existing ?? emptySettings();
|
|
475
|
+
if (!patch.preferences && !patch.models && !patch.lastPersonaPresent) return current;
|
|
476
|
+
const updated = applyPatch(current, patch);
|
|
477
|
+
atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, io, !existing);
|
|
478
|
+
return updated;
|
|
479
|
+
} finally {
|
|
480
|
+
release();
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function parseLegacyConfig(snapshot: FileSnapshot): PersonaConfigStore {
|
|
485
|
+
let parsed: unknown;
|
|
486
|
+
try {
|
|
487
|
+
parsed = JSON.parse(decodeJson(snapshot.bytes)) as unknown;
|
|
488
|
+
} catch {
|
|
489
|
+
throw new Error(`invalid JSON in legacy config: ${snapshot.file}`);
|
|
490
|
+
}
|
|
491
|
+
if (!isPlainObject(parsed)) throw new Error(`invalid legacy persona config: ${snapshot.file}`);
|
|
492
|
+
return validatePersonas(parsed);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function parseLegacyState(snapshot: FileSnapshot): string | null {
|
|
496
|
+
let parsed: unknown;
|
|
497
|
+
try {
|
|
498
|
+
parsed = JSON.parse(decodeJson(snapshot.bytes)) as unknown;
|
|
499
|
+
} catch {
|
|
500
|
+
throw new Error(`invalid JSON in legacy state: ${snapshot.file}`);
|
|
501
|
+
}
|
|
502
|
+
if (!isPlainObject(parsed)) throw new Error(`invalid legacy persona state: ${snapshot.file}`);
|
|
503
|
+
if (!Object.hasOwn(parsed, "lastPersona") || parsed.lastPersona === null || parsed.lastPersona === "") return null;
|
|
504
|
+
if (typeof parsed.lastPersona !== "string") throw new Error(`invalid lastPersona in legacy state: ${snapshot.file}`);
|
|
505
|
+
return parsed.lastPersona;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
interface LegacySnapshots {
|
|
509
|
+
configSnapshot?: FileSnapshot;
|
|
510
|
+
stateSnapshot?: FileSnapshot;
|
|
511
|
+
personas: PersonaConfigStore;
|
|
512
|
+
lastPersona: string | null;
|
|
513
|
+
warnings: string[];
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function readLegacySnapshots(options: PersonaSettingsMigrationOptions): LegacySnapshots {
|
|
517
|
+
const result: LegacySnapshots = { personas: {}, lastPersona: null, warnings: [] };
|
|
518
|
+
try {
|
|
519
|
+
const snapshot = readBoundedFile(options.legacyConfigFile);
|
|
520
|
+
if (snapshot) {
|
|
521
|
+
result.configSnapshot = snapshot;
|
|
522
|
+
result.personas = parseLegacyConfig(snapshot);
|
|
523
|
+
}
|
|
524
|
+
} catch (error) {
|
|
525
|
+
result.warnings.push(`Skipped legacy config ${options.legacyConfigFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
526
|
+
delete result.configSnapshot;
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const snapshot = readBoundedFile(options.legacyStateFile);
|
|
530
|
+
if (snapshot) {
|
|
531
|
+
result.stateSnapshot = snapshot;
|
|
532
|
+
result.lastPersona = parseLegacyState(snapshot);
|
|
533
|
+
}
|
|
534
|
+
} catch (error) {
|
|
535
|
+
result.warnings.push(`Skipped legacy state ${options.legacyStateFile}: ${error instanceof Error ? error.message : String(error)}`);
|
|
536
|
+
delete result.stateSnapshot;
|
|
537
|
+
}
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function archiveUnchanged(snapshot: FileSnapshot): boolean {
|
|
542
|
+
const backup = `${snapshot.file}.migrated`;
|
|
543
|
+
const staging = `${backup}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
544
|
+
const current = readBoundedFile(snapshot.file);
|
|
545
|
+
if (!current || !sameSnapshot(snapshot, current)) throw new Error(`legacy source changed before archive: ${snapshot.file}`);
|
|
546
|
+
let fd: number | undefined;
|
|
547
|
+
let archiveIdentity: { dev: bigint; ino: bigint } | undefined;
|
|
548
|
+
try {
|
|
549
|
+
fd = openSync(staging, "wx", 0o600);
|
|
550
|
+
writeAll(fd, snapshot.bytes);
|
|
551
|
+
fsyncSync(fd);
|
|
552
|
+
closeSync(fd);
|
|
553
|
+
fd = undefined;
|
|
554
|
+
// COPYFILE_EXCL is portable across filesystems that do not support hard links.
|
|
555
|
+
copyFileSync(staging, backup, constants.COPYFILE_EXCL);
|
|
556
|
+
const archivedPath = lstatSync(backup, { bigint: true });
|
|
557
|
+
archiveIdentity = { dev: archivedPath.dev, ino: archivedPath.ino };
|
|
558
|
+
if (!archivedPath.isFile() || archivedPath.isSymbolicLink() || archivedPath.nlink !== 1n) {
|
|
559
|
+
throw new Error(`migration archive is not a regular single-link file: ${backup}`);
|
|
560
|
+
}
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (archiveIdentity) removeLinkIfIdentity(backup, { ...snapshot, ...archiveIdentity });
|
|
563
|
+
if (errorCode(error) === "EEXIST") throw new Error(`migration archive already exists: ${backup}`);
|
|
564
|
+
throw new Error(`could not archive legacy source ${snapshot.file}: ${String(error)}`);
|
|
565
|
+
} finally {
|
|
566
|
+
if (fd !== undefined) {
|
|
567
|
+
try { closeSync(fd); } catch { /* Preserve the archive failure. */ }
|
|
568
|
+
}
|
|
569
|
+
removeTemp(staging);
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const archived = readBoundedFile(backup);
|
|
573
|
+
const source = readBoundedFile(snapshot.file);
|
|
574
|
+
if (!archiveIdentity || !archived || archived.dev !== archiveIdentity.dev || archived.ino !== archiveIdentity.ino
|
|
575
|
+
|| archived.size !== snapshot.size || !archived.bytes.equals(snapshot.bytes)
|
|
576
|
+
|| !source || !sameSnapshot(snapshot, source)) {
|
|
577
|
+
throw new Error(`legacy source changed during archive: ${snapshot.file}`);
|
|
578
|
+
}
|
|
579
|
+
unlinkSync(snapshot.file);
|
|
580
|
+
return true;
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (archiveIdentity) removeLinkIfIdentity(backup, { ...snapshot, ...archiveIdentity });
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** Import the legacy model/state files only when no canonical settings file exists. */
|
|
588
|
+
export function migratePersonaSettings(
|
|
589
|
+
file: string,
|
|
590
|
+
options: PersonaSettingsMigrationOptions,
|
|
591
|
+
io: PersonaSettingsWriteIO = {},
|
|
592
|
+
): PersonaSettingsMigrationResult {
|
|
593
|
+
const initiallyExisting = readExistingSettings(file);
|
|
594
|
+
if (initiallyExisting) return { settings: initiallyExisting, migrated: [], warnings: [] };
|
|
595
|
+
const preflight = readLegacySnapshots(options);
|
|
596
|
+
if (!preflight.configSnapshot && !preflight.stateSnapshot) {
|
|
597
|
+
const raced = readExistingSettings(file);
|
|
598
|
+
return { settings: raced ?? emptySettings(), migrated: [], warnings: preflight.warnings };
|
|
599
|
+
}
|
|
600
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
601
|
+
const sleep = io.sleep ?? sleepSync;
|
|
602
|
+
const release = io.acquireLock
|
|
603
|
+
? io.acquireLock(file, sleep)
|
|
604
|
+
: acquireFileLock(file, sleep, io.openExclusiveLock);
|
|
605
|
+
try {
|
|
606
|
+
const existing = readExistingSettings(file);
|
|
607
|
+
if (existing) return { settings: existing, migrated: [], warnings: [] };
|
|
608
|
+
|
|
609
|
+
const legacy = readLegacySnapshots(options);
|
|
610
|
+
const { configSnapshot, stateSnapshot, personas, lastPersona, warnings } = legacy;
|
|
611
|
+
if (!configSnapshot && !stateSnapshot) return { settings: emptySettings(), migrated: [], warnings };
|
|
612
|
+
|
|
613
|
+
const settings: PersonaSettings = { version: 1, preferences: {}, personas, lastPersona, selectionRevision: 0 };
|
|
614
|
+
try {
|
|
615
|
+
atomicWrite(file, `${JSON.stringify(settings, null, 2)}\n`, io, true);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
if (errorCode(error) !== "EEXIST") throw error;
|
|
618
|
+
const raced = readExistingSettings(file);
|
|
619
|
+
if (!raced) throw error;
|
|
620
|
+
return { settings: raced, migrated: [], warnings: [] };
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const migrated: string[] = [];
|
|
624
|
+
for (const snapshot of [configSnapshot, stateSnapshot]) {
|
|
625
|
+
if (!snapshot) continue;
|
|
626
|
+
try {
|
|
627
|
+
if (archiveUnchanged(snapshot)) migrated.push(snapshot.file);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
warnings.push(`Kept legacy source ${snapshot.file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return { settings, migrated, warnings };
|
|
633
|
+
} finally {
|
|
634
|
+
release();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
@@ -4,7 +4,7 @@ export const TELEMETRY_EVENT_NAME = "pi:telemetry" as const;
|
|
|
4
4
|
export const LEGACY_TELEMETRY_EVENT_NAME = "pi-persona:telemetry" as const;
|
|
5
5
|
export const LEGACY_TELEMETRY_VERSION = 1 as const;
|
|
6
6
|
export const TELEMETRY_PRODUCER_ID = "pi-persona" as const;
|
|
7
|
-
export const TELEMETRY_PRODUCER_VERSION = "1.
|
|
7
|
+
export const TELEMETRY_PRODUCER_VERSION = "1.17.1" as const;
|
|
8
8
|
|
|
9
9
|
export type InstanceStatus = "active" | "idle" | "stopped" | "stale" | (string & {});
|
|
10
10
|
export type AgentKind = "subagent" | "delegate" | "council" | "flow" | "phase" | (string & {});
|
|
@@ -54,6 +54,8 @@ export interface MessageDescriptor {
|
|
|
54
54
|
expectsReply: boolean;
|
|
55
55
|
size: number;
|
|
56
56
|
replyTo?: string;
|
|
57
|
+
/** Transport-native identity, distinct from `id`, which remains the stable telemetry-row key. */
|
|
58
|
+
wireId?: string;
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
export interface PeerDescriptor {
|
|
@@ -145,9 +147,9 @@ const KNOWN_FIELDS: Record<string, readonly string[]> = {
|
|
|
145
147
|
"instance.stopped": ["reason"], "agent.added": ["id", "label", "kind", "status", "parentId", "agent", "persona", "model"],
|
|
146
148
|
"agent.updated": ["id", "patch"], "agent.removed": ["id", "status"], "agent.cleared": [],
|
|
147
149
|
"tool.started": ["callId", "agentId", "name", "status"], "tool.finished": ["callId", "agentId", "name", "status", "durationMs"],
|
|
148
|
-
"message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
|
|
149
|
-
"message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
|
|
150
|
-
"message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
|
|
150
|
+
"message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
|
|
151
|
+
"message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
|
|
152
|
+
"message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
|
|
151
153
|
"peers.snapshot": ["peers"],
|
|
152
154
|
};
|
|
153
155
|
|
|
@@ -198,7 +200,7 @@ function validKnownPayload(type: string, payload: Record<string, unknown>): bool
|
|
|
198
200
|
if (type === "agent.removed") return stringField(payload, "id") && (payload.status === undefined || ["done", "failed", "stopped"].includes(payload.status as string));
|
|
199
201
|
if (type === "tool.started") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && stringField(payload, "status") && !TERMINAL_TOOL_STATUS.includes(payload.status as string);
|
|
200
202
|
if (type === "tool.finished") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && ["done", "failed"].includes(payload.status as string) && numberField(payload, "durationMs", { min: 0 });
|
|
201
|
-
if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo"));
|
|
203
|
+
if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo")) && (payload.wireId === undefined || stringField(payload, "wireId"));
|
|
202
204
|
if (type === "peers.snapshot") return Array.isArray(payload.peers) && payload.peers.every((peer) => { const p = objectPayload(peer); return p !== undefined && stringField(p, "sessionId") && stringField(p, "displayName") && stringField(p, "persona", false) && stringField(p, "model", false) && numberField(p, "contextPercent", { min: 0, max: 100 }) && typeof p.status === "string" && numberField(p, "sent", { integer: true, min: 0 }) && numberField(p, "received", { integer: true, min: 0 }); });
|
|
203
205
|
return true;
|
|
204
206
|
}
|