@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
|
@@ -1,215 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* needs settings gets an entry, others don't. Two personas that share a strategy
|
|
5
|
-
* keep independent configs. Today it carries per-agent model assignments (so an
|
|
6
|
-
* ensemble like magi can run its cores on *different* models); more keys later.
|
|
2
|
+
* Pure per-persona model configuration helpers. Persistence is owned exclusively
|
|
3
|
+
* by settings-store.ts (<agentDir>/persona/settings.json).
|
|
7
4
|
*/
|
|
8
5
|
|
|
9
|
-
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
|
|
10
|
-
import { dirname } from "node:path";
|
|
11
|
-
|
|
12
6
|
export interface PersonaConfig {
|
|
13
|
-
/**
|
|
7
|
+
/** Participant key → provider-qualified model id. */
|
|
14
8
|
models?: Record<string, string>;
|
|
15
9
|
}
|
|
16
10
|
|
|
17
11
|
export type PersonaConfigStore = Record<string, PersonaConfig>;
|
|
18
12
|
|
|
19
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
20
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Read the store. NOTE: this reader WRITES on one path — an unusable file is copied to
|
|
25
|
-
* `${file}.bak` before the next save replaces it (once; an existing backup is never touched).
|
|
26
|
-
*/
|
|
27
|
-
export function readPersonaConfigs(file: string): PersonaConfigStore {
|
|
28
|
-
let text: string;
|
|
29
|
-
try {
|
|
30
|
-
text = readFileSync(file, "utf8");
|
|
31
|
-
} catch {
|
|
32
|
-
return {}; // no config yet (the common case) or unreadable → start empty
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
const parsed: unknown = JSON.parse(text);
|
|
36
|
-
// Valid JSON that isn't a config object is as unusable as unparseable text — and the next
|
|
37
|
-
// save replaces it just the same, so it gets the same backup treatment.
|
|
38
|
-
if (!isPlainObject(parsed)) throw new Error("not a config object");
|
|
39
|
-
const store: PersonaConfigStore = {};
|
|
40
|
-
for (const [name, cfg] of Object.entries(parsed)) {
|
|
41
|
-
if (!isPlainObject(cfg)) continue;
|
|
42
|
-
const entry: PersonaConfig = {};
|
|
43
|
-
if (isPlainObject(cfg.models)) {
|
|
44
|
-
const models: Record<string, string> = {};
|
|
45
|
-
for (const [agent, model] of Object.entries(cfg.models)) {
|
|
46
|
-
if (typeof model === "string") models[agent] = model;
|
|
47
|
-
}
|
|
48
|
-
entry.models = models;
|
|
49
|
-
}
|
|
50
|
-
store[name] = entry;
|
|
51
|
-
}
|
|
52
|
-
return store;
|
|
53
|
-
} catch {
|
|
54
|
-
// An unusable file is about to be overwritten by the next save; keep it beside the
|
|
55
|
-
// config so the assignments stay recoverable instead of vanishing silently. `wx` makes
|
|
56
|
-
// this the FIRST copy only: the underlying fault (a sync client, a failing disk) tends to
|
|
57
|
-
// recur, and a second bad parse would otherwise overwrite the one copy that still had the
|
|
58
|
-
// user's assignments in it.
|
|
59
|
-
try {
|
|
60
|
-
writeFileSync(`${file}.bak`, text, { encoding: "utf8", flag: "wx" });
|
|
61
|
-
} catch {
|
|
62
|
-
/* best effort — an existing backup, or an unwritable dir, must not stop the session */
|
|
63
|
-
}
|
|
64
|
-
return {};
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Injection seams for the rename retry below (tests drive it on any OS). */
|
|
69
|
-
export interface ConfigWriteIO {
|
|
70
|
-
rename?: (from: string, to: string) => void;
|
|
71
|
-
sleep?: (ms: number) => void;
|
|
72
|
-
/** Test seam for the cross-process lock around the read/merge/write transaction. */
|
|
73
|
-
acquireLock?: (file: string, sleep: (ms: number) => void) => () => void;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// A handle held by a virus scanner, a sync client or another pi instance mid-read is transient,
|
|
77
|
-
// so a few short waits usually win the race — 25+50+75 ms of backoff at worst, on a save.
|
|
78
|
-
const RENAME_ATTEMPTS = 4;
|
|
79
|
-
const RENAME_BACKOFF_MS = 25;
|
|
80
|
-
const LOCK_ATTEMPTS = 80;
|
|
81
|
-
const LOCK_BACKOFF_MS = 25;
|
|
82
|
-
const STALE_LOCK_MS = 5 * 60 * 1000;
|
|
83
|
-
|
|
84
|
-
function sleepSync(ms: number): void {
|
|
85
|
-
// The whole store API is synchronous (this runs on a model assignment, not on a hot path),
|
|
86
|
-
// so the backoff has to block; Atomics.wait is the standard synchronous sleep.
|
|
87
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Acquire a small sidecar lock so two independent Pi processes cannot both read the same old
|
|
91
|
-
* config and race their read-modify-write transactions. `wx` is the portable atomic primitive on
|
|
92
|
-
* POSIX and Windows. A very old lock is treated as abandoned after a crashed process; active
|
|
93
|
-
* writers renew no state because the critical section is synchronous and short. */
|
|
94
|
-
function acquireFileLock(file: string, sleep: (ms: number) => void): () => void {
|
|
95
|
-
const lock = `${file}.lock`;
|
|
96
|
-
for (let attempt = 1; attempt <= LOCK_ATTEMPTS; attempt++) {
|
|
97
|
-
try {
|
|
98
|
-
const fd = openSync(lock, "wx");
|
|
99
|
-
try {
|
|
100
|
-
writeSync(fd, `${process.pid}\n`, null, "utf8");
|
|
101
|
-
} catch (error) {
|
|
102
|
-
try {
|
|
103
|
-
closeSync(fd);
|
|
104
|
-
} finally {
|
|
105
|
-
try {
|
|
106
|
-
unlinkSync(lock);
|
|
107
|
-
} catch {
|
|
108
|
-
/* best effort; retain the original write error */
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
throw error;
|
|
112
|
-
}
|
|
113
|
-
return () => {
|
|
114
|
-
try {
|
|
115
|
-
closeSync(fd);
|
|
116
|
-
} finally {
|
|
117
|
-
try {
|
|
118
|
-
unlinkSync(lock);
|
|
119
|
-
} catch {
|
|
120
|
-
/* another cleanup path or an external remover already won */
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
} catch (error) {
|
|
125
|
-
const code = (error as NodeJS.ErrnoException).code;
|
|
126
|
-
if (code !== "EEXIST") throw error;
|
|
127
|
-
try {
|
|
128
|
-
if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) unlinkSync(lock);
|
|
129
|
-
} catch {
|
|
130
|
-
/* It disappeared between stat/unlink; retry normally. */
|
|
131
|
-
}
|
|
132
|
-
if (attempt < LOCK_ATTEMPTS) {
|
|
133
|
-
sleep(LOCK_BACKOFF_MS);
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
throw new Error(`timed out acquiring persona config lock: ${lock}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
throw new Error(`timed out acquiring persona config lock: ${lock}`);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* MERGE `store` into the file (read-modify-write), then save it.
|
|
144
|
-
*
|
|
145
|
-
* A removal is NOT expressible: an assignment missing from `store` is preserved, not deleted.
|
|
146
|
-
* The merge exists so a concurrent pi instance's save survives ours, and it cannot tell "the
|
|
147
|
-
* user cleared this" from "this store was read before that was written" — a clearing gesture
|
|
148
|
-
* would need its own delete-aware call.
|
|
149
|
-
*/
|
|
150
|
-
export function writePersonaConfigs(file: string, store: PersonaConfigStore, io: ConfigWriteIO = {}): void {
|
|
151
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
152
|
-
const sleep = io.sleep ?? sleepSync;
|
|
153
|
-
const release = (io.acquireLock ?? acquireFileLock)(file, sleep);
|
|
154
|
-
try {
|
|
155
|
-
// The caller holds a store read at session start; another pi instance in the same workspace
|
|
156
|
-
// may have saved since. Re-read and merge so its work survives our whole-store write — down
|
|
157
|
-
// to the agent, since two instances of the *same* persona assign different roster members.
|
|
158
|
-
const merged: PersonaConfigStore = { ...readPersonaConfigs(file) };
|
|
159
|
-
for (const [persona, cfg] of Object.entries(store)) {
|
|
160
|
-
const prev = merged[persona];
|
|
161
|
-
merged[persona] = prev ? { ...prev, ...cfg, models: { ...prev.models, ...cfg.models } } : cfg;
|
|
162
|
-
}
|
|
163
|
-
const payload = `${JSON.stringify(merged, null, 2)}\n`;
|
|
164
|
-
// Temp file + same-volume rename, so a crash mid-write can never leave a truncated
|
|
165
|
-
// config.json behind. That is atomic on POSIX; Windows is NOT the same — rename fails with
|
|
166
|
-
// EPERM/EBUSY while ANY other handle holds the target open (Defender, a sync client, a second
|
|
167
|
-
// pi instance mid-read), and the persona folder is an ordinary user directory where that is
|
|
168
|
-
// routine. So a contended rename is retried, and then written in place: a non-atomic save
|
|
169
|
-
// that lands beats an atomic one that loses the user's assignments every session.
|
|
170
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
171
|
-
const rename = io.rename ?? renameSync;
|
|
172
|
-
writeFileSync(tmp, payload, "utf8");
|
|
173
|
-
for (let attempt = 1; ; attempt++) {
|
|
174
|
-
try {
|
|
175
|
-
rename(tmp, file);
|
|
176
|
-
return;
|
|
177
|
-
} catch (err) {
|
|
178
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
179
|
-
if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
|
|
180
|
-
sleep(RENAME_BACKOFF_MS * attempt);
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
try {
|
|
184
|
-
// The scratch file must not be left behind for every pid that ever tried, whether
|
|
185
|
-
// the in-place write succeeds or throws (the caller still sees a real failure).
|
|
186
|
-
writeFileSync(file, payload, "utf8");
|
|
187
|
-
} finally {
|
|
188
|
-
try {
|
|
189
|
-
rmSync(tmp, { force: true });
|
|
190
|
-
} catch {
|
|
191
|
-
/* best effort */
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
} finally {
|
|
198
|
-
release();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
13
|
/** The model assignments for a persona (empty object when none configured). */
|
|
203
14
|
export function personaModels(store: PersonaConfigStore, persona: string): Record<string, string> {
|
|
204
|
-
return store[persona]?.models ?? {};
|
|
15
|
+
return Object.hasOwn(store, persona) ? store[persona]?.models ?? {} : {};
|
|
205
16
|
}
|
|
206
17
|
|
|
207
|
-
/** Merge model assignments
|
|
18
|
+
/** Merge session model assignments without mutating the owner's saved snapshot. */
|
|
208
19
|
export function withPersonaModels(
|
|
209
20
|
store: PersonaConfigStore,
|
|
210
21
|
persona: string,
|
|
211
22
|
models: Record<string, string>,
|
|
212
23
|
): PersonaConfigStore {
|
|
213
|
-
const prev = store[persona] ?? {};
|
|
24
|
+
const prev = Object.hasOwn(store, persona) ? store[persona] ?? {} : {};
|
|
214
25
|
return { ...store, [persona]: { ...prev, models: { ...prev.models, ...models } } };
|
|
215
26
|
}
|
|
@@ -30,7 +30,7 @@ export interface PersonaHost {
|
|
|
30
30
|
setThinkingLevel(level: ThinkingLevel): void;
|
|
31
31
|
getModel(): ModelHandle | undefined;
|
|
32
32
|
findModel(ref: string): ModelHandle | undefined;
|
|
33
|
-
setModel(model: ModelHandle): void | Promise<void>;
|
|
33
|
+
setModel(model: ModelHandle): void | boolean | Promise<void | boolean>;
|
|
34
34
|
setStatus(text: string | undefined): void;
|
|
35
35
|
/** Optional: a declared-but-unavailable model/thinking would otherwise fail silently. */
|
|
36
36
|
warn?(message: string): void;
|
|
@@ -108,11 +108,24 @@ export class PersonaController {
|
|
|
108
108
|
if (persona.model) {
|
|
109
109
|
const model = this.host.findModel(persona.model);
|
|
110
110
|
if (model) {
|
|
111
|
-
|
|
111
|
+
const firstOverride = !this.modelOverridden;
|
|
112
|
+
if (firstOverride) {
|
|
112
113
|
this.baselineModel = this.host.getModel();
|
|
113
114
|
this.modelOverridden = true;
|
|
114
115
|
}
|
|
115
|
-
await this.host.setModel(model);
|
|
116
|
+
const applied = await this.host.setModel(model);
|
|
117
|
+
if (applied === false) {
|
|
118
|
+
// Pi's setModel returns false when the provider has no configured auth. Do not
|
|
119
|
+
// leave a phantom override behind: a later persona or deactivation must not
|
|
120
|
+
// restore a baseline for a model that never became active.
|
|
121
|
+
if (firstOverride) {
|
|
122
|
+
this.modelOverridden = false;
|
|
123
|
+
this.baselineModel = undefined;
|
|
124
|
+
}
|
|
125
|
+
this.host.warn?.(
|
|
126
|
+
`persona "${persona.name}" declared model "${persona.model}" but provider authentication is not configured; keeping the current model`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
116
129
|
} else {
|
|
117
130
|
this.host.warn?.(
|
|
118
131
|
`persona "${persona.name}" declared model "${persona.model}" which is not configured; keeping the current model`,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* > the session model (the default, NOT an assignment — it must not suppress the picker).
|
|
17
17
|
*
|
|
18
18
|
* The persisted assignment KEY is `agent` for a bare member — the historical shape, so every
|
|
19
|
-
*
|
|
19
|
+
* migrated model assignments keep applying — and `agent#<hash-of-agent+role>` for a member carrying a
|
|
20
20
|
* role. The hash keeps the full role as the identity without persisting the role PROMPT (which is
|
|
21
21
|
* an instruction, not a key, and would leak strategy prose into the user's config file). The human
|
|
22
22
|
* label stays separate, so the picker and the tree can still say `reviewer · SECURITY`.
|