@neruok/pi-advisor 0.0.0-stage → 0.1.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/lib/session.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { AssistantMessage, ModelsSimpleStreamOptions, Context, Usage } from '@earendil-works/pi-ai';
3
+ import { ADVISOR_PROMPT } from './prompt.ts';
4
+ import { replay, type Replay } from './replay.ts';
5
+ import { contextUsage, type ContextUsage } from './context.ts';
6
+ import { AdvisorError, LIMITS, parseTimeoutMs, addUsage, checkLimit, failure, parseInput, replyText, zeroUsage, completionCategory, terminalDiagnostics, type Diagnostics, type Failure, type Selection, type SelectionSource, type UsageTotals } from './protocol.ts';
7
+
8
+ type Exchange = { role: 'user' | 'assistant'; text: string; replay?: Replay; usage?: Usage };
9
+ type Session = { id: string; label: string; model?: Selection; selectionSource?: SelectionSource; timeoutMs?: number; contextWindow?: number; history: Exchange[]; usage: Usage; totalUsageComplete: boolean; pending?: AbortController };
10
+ export type Phase = 'preparing' | 'waiting';
11
+ export type Dependencies = {
12
+ prepare: (reportSelection?: (model: Selection, source: SelectionSource) => void, reportTimeout?: (timeoutMs: number) => void) => Promise<Selection>;
13
+ getContextWindow: (model: Selection) => number;
14
+ complete: (model: Selection, context: Context, options: ModelsSimpleStreamOptions) => Promise<AssistantMessage>;
15
+ progress?: (phase: Phase) => void;
16
+ };
17
+ export type Advice = { ok: true; advisory: true; session: string; response: string; turns: number; model: Selection; usage: Usage; usageComplete: boolean; contextUsage: ContextUsage } & UsageTotals;
18
+ export type Metadata = { session: string; label: string; turns: number; model: Selection; busy: boolean; historyBytes: number; contextUsage: ContextUsage } & UsageTotals;
19
+ const size = (history: Exchange[]): number => Buffer.byteLength(JSON.stringify(history.map(({ role, text, replay }) => ({ role, text, ...(replay ? { replay } : {}) }))));
20
+ const retainedContext = (session: Session): ContextUsage => contextUsage(context(session.history, session.model!).messages, session.contextWindow!);
21
+ const totals = (session: Session, pending = Boolean(session.pending)): UsageTotals => ({ totalUsage: structuredClone(session.usage), totalUsageComplete: session.totalUsageComplete && !pending });
22
+ function progress(deps: Dependencies, phase: Phase): void {
23
+ // Presentation failure must never replace a consultation outcome.
24
+ try { deps.progress?.(phase); } catch { /* The host owns display diagnostics. */ }
25
+ }
26
+
27
+ function context(history: Exchange[], model: Selection): Context {
28
+ return { messages: [
29
+ { role: 'system', content: ADVISOR_PROMPT, toolsAdded: [], timestamp: 0 },
30
+ ...history.map(entry => entry.role === 'user'
31
+ ? { role: 'user' as const, content: entry.text, timestamp: 0 }
32
+ : { role: 'assistant' as const, content: entry.replay ? structuredClone(entry.replay.content) : [{ type: 'text' as const, text: entry.text }], provider: model.provider, model: model.model, api: entry.replay?.api ?? 'advisor-text', timestamp: 0, stopReason: 'stop' as const, usage: structuredClone(entry.usage ?? zeroUsage()) })
33
+ ] };
34
+ }
35
+ // The caller owns the race. Late provider completion can never commit consultation state.
36
+ async function interruptible<T>(operation: () => Promise<T>, signal: AbortSignal): Promise<T> {
37
+ if (signal.aborted) throw signal.reason;
38
+ let abort: (() => void) | undefined;
39
+ const cancelled = new Promise<never>((_resolve, reject) => { abort = () => reject(signal.reason); signal.addEventListener('abort', abort, { once: true }); });
40
+ try { return await Promise.race([Promise.resolve().then(() => { if (signal.aborted) throw signal.reason; return operation(); }), cancelled]); }
41
+ finally { if (abort) signal.removeEventListener('abort', abort); }
42
+ }
43
+
44
+ export class Consultations {
45
+ private readonly sessions = new Map<string, Session>();
46
+ private readonly limits: typeof LIMITS;
47
+ constructor(overrides: Partial<Pick<typeof LIMITS, 'timeoutMs'>> = {}) {
48
+ if (Object.keys(overrides).some(key => key !== 'timeoutMs')) throw new AdvisorError('invalid-argument');
49
+ for (const value of Object.values(overrides)) if (!Number.isSafeInteger(value) || value < 1) throw new AdvisorError('invalid-argument');
50
+ if (overrides.timeoutMs !== undefined) parseTimeoutMs(overrides.timeoutMs, 'invalid-argument');
51
+ this.limits = { ...LIMITS, ...overrides };
52
+ }
53
+ list(): Metadata[] {
54
+ return [...this.sessions.values()].filter((s): s is Session & { model: Selection } => Boolean(s.model)).map(s => ({
55
+ session: s.id, label: s.label, turns: s.history.length / 2, model: { ...s.model }, busy: Boolean(s.pending),
56
+ ...totals(s),
57
+ historyBytes: size(s.history), contextUsage: retainedContext(s)
58
+ }));
59
+ }
60
+ close(id: string): { ok: true; session: string } | Failure {
61
+ const session = this.sessions.get(id);
62
+ if (!session) return failure(new AdvisorError('not-found'), zeroUsage(), id);
63
+ if (session.pending) return failure(new AdvisorError('busy'), zeroUsage(), id);
64
+ this.sessions.delete(id);
65
+ return { ok: true, session: id };
66
+ }
67
+ clear(): void {
68
+ for (const session of this.sessions.values()) session.pending?.abort(new AdvisorError('cancelled'));
69
+ this.sessions.clear();
70
+ }
71
+ async send(value: unknown, deps: Dependencies, signal?: AbortSignal): Promise<Advice | Failure> {
72
+ let session: Session | undefined;
73
+ let input: ReturnType<typeof parseInput> | undefined;
74
+ const requestedSession = value && typeof value === 'object' && 'session' in value && typeof value.session === 'string' && value.session.trim() ? value.session : undefined;
75
+ let controller: AbortController | undefined;
76
+ let timer: ReturnType<typeof setTimeout> | undefined;
77
+ let relay: (() => void) | undefined;
78
+ let usage = zeroUsage();
79
+ let usageComplete = true;
80
+ const diagnosticsEnabled = Boolean(value && typeof value === 'object' && 'diagnostics' in value && value.diagnostics === true);
81
+ let phase: Diagnostics['phase'] = 'validation';
82
+ let terminalHint: Pick<Diagnostics, 'category' | 'code' | 'httpStatus'> | undefined;
83
+ let diagnosticModel: Selection | undefined;
84
+ let selectionSource: SelectionSource = 'unknown';
85
+ let timeoutMs: number = this.limits.timeoutMs, finished = false;
86
+ const started = performance.now();
87
+ const armDeadline = (): void => {
88
+ if (timer) clearTimeout(timer);
89
+ const remaining = timeoutMs - (performance.now() - started);
90
+ if (remaining <= 0) throw new AdvisorError('timeout');
91
+ timer = setTimeout(() => controller!.abort(new AdvisorError('timeout')), remaining);
92
+ };
93
+ try {
94
+ input = parseInput(value);
95
+ if (signal?.aborted) throw new AdvisorError('cancelled');
96
+ session = this.reserve(input);
97
+ diagnosticModel = session.model ? { ...session.model } : undefined;
98
+ selectionSource = session.selectionSource ?? 'unknown';
99
+ controller = new AbortController();
100
+ session.pending = controller;
101
+ relay = () => controller!.abort(new AdvisorError('cancelled'));
102
+ signal?.addEventListener('abort', relay, { once: true });
103
+ timeoutMs = session.timeoutMs ?? this.limits.timeoutMs;
104
+ armDeadline();
105
+ const pending: Exchange[] = [...session.history, { role: 'user', text: input.message }];
106
+ phase = 'preparation';
107
+ const selected = session.model ?? await interruptible(() => {
108
+ progress(deps, 'preparing');
109
+ if (controller!.signal.aborted) throw controller!.signal.reason;
110
+ return deps.prepare((model, source) => {
111
+ // A late preparation must not mutate pinned state or the returned diagnostics.
112
+ if (!finished && phase === 'preparation' && !controller!.signal.aborted) { diagnosticModel = { ...model }; selectionSource = source; }
113
+ }, configured => {
114
+ if (!finished && phase === 'preparation' && !controller!.signal.aborted) {
115
+ timeoutMs = parseTimeoutMs(configured);
116
+ armDeadline();
117
+ }
118
+ });
119
+ }, controller.signal);
120
+ if (performance.now() - started >= timeoutMs) throw new AdvisorError('timeout');
121
+ const model = { ...selected };
122
+ const window = session.contextWindow ?? deps.getContextWindow?.(model);
123
+ if (!Number.isSafeInteger(window) || window < 1) throw new AdvisorError('model-unavailable');
124
+ // Pin before awaiting model work. The map identity guards against lifecycle invalidation.
125
+ session.model = model;
126
+ session.contextWindow = window;
127
+ session.selectionSource = selectionSource;
128
+ session.timeoutMs = timeoutMs;
129
+ diagnosticModel = { ...model };
130
+ checkLimit('context-tokens', window, contextUsage(context(pending, model).messages, window).tokens);
131
+ phase = 'completion';
132
+ const reply = await interruptible(() => {
133
+ progress(deps, 'waiting');
134
+ if (controller!.signal.aborted) throw controller!.signal.reason;
135
+ if (performance.now() - started >= timeoutMs) throw new AdvisorError('timeout');
136
+ usageComplete = false;
137
+ return deps.complete(model, context(pending, model), {
138
+ signal: controller!.signal, timeoutMs: Math.max(1, Math.floor(timeoutMs - (performance.now() - started))), maxRetries: 0,
139
+ cacheRetention: 'short', sessionId: session!.id,
140
+ ...(model.reasoning && model.reasoning !== 'default' && model.reasoning !== 'off' ? { reasoning: model.reasoning } : {})
141
+ });
142
+ }, controller.signal);
143
+ phase = 'response-validation';
144
+ if (reply.stopReason === 'error') terminalHint = diagnosticsEnabled ? terminalDiagnostics(reply, model.provider) : { category: 'provider-error' };
145
+ if (reply.stopReason === 'aborted') terminalHint = { category: 'provider-aborted' };
146
+ usage = addUsage(usage, reply.usage);
147
+ usageComplete = true;
148
+ const response = replyText(reply);
149
+ const opaque = replay(reply, model);
150
+ const next: Exchange[] = [...pending, { role: 'assistant', text: response, usage: structuredClone(usage), ...(opaque ? { replay: opaque } : {}) }];
151
+ const nextContext = contextUsage(context(next, model).messages, window);
152
+ checkLimit('context-tokens', window, nextContext.tokens);
153
+ phase = 'commit';
154
+ if (controller.signal.aborted || this.sessions.get(session.id) !== session) throw new AdvisorError('cancelled');
155
+ if (performance.now() - started >= timeoutMs) throw new AdvisorError('timeout');
156
+ session.history = next;
157
+ session.usage = addUsage(session.usage, usage);
158
+ return { ok: true, advisory: true, session: session.id, response, turns: next.length / 2, model: { ...model }, usage, usageComplete, contextUsage: nextContext, ...totals(session, false) };
159
+ } catch (error) {
160
+ if (session && controller && session.history.length === 0 && this.sessions.get(session.id) === session) this.sessions.delete(session.id);
161
+ // Only the request owner updates accounting. Rejected replies still consume resources.
162
+ if (session && controller && session.history.length > 0 && this.sessions.get(session.id) === session) {
163
+ session.usage = addUsage(session.usage, usage);
164
+ session.totalUsageComplete &&= usageComplete;
165
+ }
166
+ const existing = requestedSession ? this.sessions.get(requestedSession) : undefined;
167
+ const result = failure(error, usage, requestedSession, usageComplete);
168
+ if (diagnosticsEnabled) result.error.diagnostics = {
169
+ phase,
170
+ category: error instanceof AdvisorError ? 'advisor-error' : phase === 'completion' ? completionCategory(error) : 'local-error',
171
+ ...terminalHint,
172
+ ...(diagnosticModel ? { model: { ...diagnosticModel }, selectionSource } : {})
173
+ };
174
+ return { ...result, ...(existing ? { ...totals(existing, existing.pending !== controller && Boolean(existing.pending)), ...(existing.contextWindow && existing.model ? { contextUsage: retainedContext(existing) } : {}) } : {}) };
175
+ } finally {
176
+ finished = true;
177
+ if (timer) clearTimeout(timer);
178
+ if (relay) signal?.removeEventListener('abort', relay);
179
+ if (session && controller && session.pending === controller) session.pending = undefined;
180
+ }
181
+ }
182
+ private reserve(input: ReturnType<typeof parseInput>): Session {
183
+ if (input.session !== undefined) {
184
+ const existing = this.sessions.get(input.session);
185
+ if (!existing) throw new AdvisorError('not-found');
186
+ if (existing.pending) throw new AdvisorError('busy');
187
+ return existing;
188
+ }
189
+ const session: Session = { id: 'adv_' + randomUUID(), label: Array.from(input.message.trim().replace(/\s+/gu, ' ')).slice(0, 80).join(''), history: [], usage: zeroUsage(), totalUsageComplete: true };
190
+ this.sessions.set(session.id, session);
191
+ return session;
192
+ }
193
+ }
@@ -0,0 +1,105 @@
1
+ import { getAgentDir, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import type { Api, Model } from '@earendil-works/pi-ai';
3
+ import { AdvisorError, LIMITS, REASONING_LEVELS, parseSelection, parseTimeoutMs, type Reasoning, type Selection } from './protocol.ts';
4
+ import { argumentCompletions, type CompletionContext } from './completions.ts';
5
+ import { assertReasoning, supportedReasoning } from './reasoning.ts';
6
+ import { selectModel } from './model-picker.ts';
7
+ import { loadSettings, saveSettings, settingsPaths, type LoadedSettings, type Scope } from './settings.ts';
8
+
9
+ const USAGE = 'Usage: /advisor [--global|--project] [show | model <provider> <model> | reasoning [level] | timeout [milliseconds|default]]. Bare command opens model selection with UI. Saves default to global.';
10
+ export function resolveModel(ctx: ExtensionContext, pair: Selection, requireAuth: boolean): Model<Api> {
11
+ const model = ctx.modelRegistry.find(pair.provider, pair.model);
12
+ if (!model || model.api === 'pi-virtual' || (requireAuth && !ctx.modelRegistry.hasConfiguredAuth(model))) throw new AdvisorError('model-unavailable');
13
+ assertReasoning(model, pair.reasoning);
14
+ return model;
15
+ }
16
+ function parseCommand(args: string): { scope: Scope; show: boolean; pair?: Selection; reasoning?: Reasoning; reasoningQuery?: boolean; timeout?: number | 'default'; timeoutQuery?: boolean } {
17
+ const tokens = args.trim() ? args.trim().split(/\s+/) : [];
18
+ const flags = tokens.filter(t => t.startsWith('--'));
19
+ if (flags.length > 1 || flags.some(f => f !== '--global' && f !== '--project')) throw new AdvisorError('invalid-argument');
20
+ const scope = flags[0] === '--project' ? 'project' : 'global';
21
+ const rest = tokens.filter(t => !t.startsWith('--'));
22
+ if (!rest.length) return { scope, show: false };
23
+ if (rest.length === 1 && rest[0] === 'show') return { scope, show: true };
24
+ if (rest[0] === 'timeout') {
25
+ if (rest.length === 1) return { scope, show: false, timeoutQuery: true };
26
+ if (rest.length === 2 && rest[1] === 'default') return { scope, show: false, timeout: 'default' };
27
+ if (rest.length === 2 && /^[0-9]+$/.test(rest[1])) return { scope, show: false, timeout: parseTimeoutMs(Number(rest[1]), 'invalid-argument') };
28
+ throw new AdvisorError('invalid-argument');
29
+ }
30
+ if (rest[0] === 'reasoning') {
31
+ if (rest.length === 1) return { scope, show: false, reasoningQuery: true };
32
+ if (rest.length === 2 && REASONING_LEVELS.includes(rest[1] as Reasoning)) return { scope, show: false, reasoning: rest[1] as Reasoning };
33
+ throw new AdvisorError('invalid-argument');
34
+ }
35
+ if (rest.length === 3 && rest[0] === 'model') return { scope, show: false, pair: parseSelection({ provider: rest[1], model: rest[2] }) };
36
+ throw new AdvisorError('invalid-argument');
37
+ }
38
+ function describe(loaded: LoadedSettings): string {
39
+ const pair = loaded.settings.model;
40
+ return `Advisor model: ${pair ? `${pair.provider}/${pair.model} (${loaded.source})` : 'not configured'}. Reasoning: ${pair?.reasoning ?? 'default (legacy provider behavior)'}. Timeout: ${loaded.settings.timeoutMs ?? LIMITS.timeoutMs} ms (${loaded.timeoutSource ?? 'default'}). Configuration changes affect new consultations only.`;
41
+ }
42
+ async function pick(ctx: ExtensionCommandContext, current?: Selection): Promise<Selection | undefined> {
43
+ const models = ctx.modelRegistry.getAvailable().filter(m => m.api !== 'pi-virtual');
44
+ if (!models.length) throw new AdvisorError('model-unavailable');
45
+ return selectModel(ctx, 'Advisor model', models, current);
46
+ }
47
+ export function registerSettingsCommand(pi: ExtensionAPI): (ctx?: CompletionContext) => void {
48
+ let context: CompletionContext | undefined;
49
+ let contextRevision = 0;
50
+ const report = (ctx: ExtensionCommandContext, text: string, level: 'info' | 'error'): void => {
51
+ if (ctx.hasUI) ctx.ui.notify(text, level);
52
+ else pi.sendMessage({ customType: 'advisor-settings', content: text, display: true, details: { level } }, { triggerTurn: false });
53
+ };
54
+ pi.registerCommand('advisor', {
55
+ description: 'Configure the isolated advisor: picker, show, model <provider> <model>, reasoning [level], timeout [milliseconds|default], --global/--project',
56
+ async getArgumentCompletions(prefix) {
57
+ const revision = contextRevision;
58
+ const items = await argumentCompletions(prefix, context);
59
+ return revision === contextRevision ? items : null;
60
+ },
61
+ async handler(args, ctx) {
62
+ const paths = settingsPaths(ctx.cwd, getAgentDir());
63
+ let saveStarted = false;
64
+ try {
65
+ let command: ReturnType<typeof parseCommand>;
66
+ try { command = parseCommand(args); } catch { report(ctx, USAGE, 'error'); return; }
67
+ const loaded = await loadSettings(paths, ctx.isProjectTrusted());
68
+ if (command.timeoutQuery) { report(ctx, describe(loaded), 'info'); return; }
69
+ if (command.reasoningQuery) {
70
+ const pair = loaded.settings.model;
71
+ if (!pair) throw new AdvisorError('not-configured');
72
+ const model = ctx.modelRegistry.find(pair.provider, pair.model);
73
+ if (!model || model.api === 'pi-virtual') throw new AdvisorError('model-unavailable');
74
+ report(ctx, `${describe(loaded)}\nSupported advisor reasoning: ${supportedReasoning(model).join(', ')}.\nUse /advisor [--global|--project] reasoning <level>. Higher reasoning can increase cost and latency.`, 'info');
75
+ return;
76
+ }
77
+ if (command.show || (!command.pair && command.reasoning === undefined && command.timeout === undefined && !ctx.hasUI)) { report(ctx, `${describe(loaded)}\n${USAGE}\nglobal: ${paths.global}\nproject: ${paths.project}${ctx.isProjectTrusted() ? '' : ' (ignored: untrusted)'}`, 'info'); return; }
78
+ if (command.scope === 'project' && !ctx.isProjectTrusted()) throw new AdvisorError('settings-unavailable');
79
+ await ctx.waitForIdle();
80
+ if (command.timeout !== undefined) {
81
+ const settings = command.timeout === 'default' ? {} : { timeoutMs: command.timeout };
82
+ saveStarted = true;
83
+ await saveSettings(paths, command.scope, settings, ctx.isProjectTrusted(), 'model');
84
+ report(ctx, `Saved ${command.scope} settings at ${paths[command.scope]}.\n${describe(await loadSettings(paths, ctx.isProjectTrusted()))}`, 'info');
85
+ return;
86
+ }
87
+ let pair: Selection | undefined;
88
+ if (command.reasoning !== undefined) {
89
+ const target = command.scope === 'global' ? await loadSettings(paths, false) : loaded;
90
+ if (!target.settings.model) throw new AdvisorError('not-configured');
91
+ pair = { ...target.settings.model, reasoning: command.reasoning };
92
+ } else pair = command.pair ?? await pick(ctx, loaded.settings.model);
93
+ if (!pair) { report(ctx, 'Advisor configuration cancelled. Settings unchanged.', 'info'); return; }
94
+ resolveModel(ctx, pair, false);
95
+ saveStarted = true;
96
+ await saveSettings(paths, command.scope, { model: pair }, ctx.isProjectTrusted(), 'timeoutMs');
97
+ report(ctx, `Saved ${command.scope} settings at ${paths[command.scope]}.\n${describe(await loadSettings(paths, ctx.isProjectTrusted()))}`, 'info');
98
+ } catch (error) {
99
+ const message = error instanceof AdvisorError ? error.message : 'Cannot configure advisor settings.';
100
+ report(ctx, `${message}\n${saveStarted ? 'A save may have completed. Inspect before retrying.' : 'This command did not save settings.'}\nUse /advisor show to inspect.`, 'error');
101
+ }
102
+ }
103
+ });
104
+ return ctx => { context = ctx; contextRevision++; };
105
+ }
@@ -0,0 +1,99 @@
1
+ import { constants } from 'node:fs';
2
+ import { lstat, open, mkdir, rename, link, unlink, type FileHandle } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { dirname, join } from 'node:path';
5
+ import { withFileMutationQueue } from '@earendil-works/pi-coding-agent';
6
+ import { AdvisorError, object, parseSelection, parseTimeoutMs, type Selection } from './protocol.ts';
7
+
8
+ const MAX_SETTINGS_BYTES = 16384;
9
+
10
+ export type Settings = { model?: Selection; timeoutMs?: number };
11
+ export type Scope = 'global' | 'project';
12
+ export type SettingsPaths = Record<Scope, string>;
13
+ export type LoadedSettings = { settings: Settings; source?: Scope; timeoutSource?: Scope };
14
+ export function settingsPaths(cwd: string, agentDir: string): SettingsPaths { return { global: join(agentDir, 'advisor.json'), project: join(cwd, '.pi', 'advisor.json') }; }
15
+ export function parseSettings(value: unknown): Settings {
16
+ const settings = object(value, ['model', 'timeoutMs'], 'invalid-config');
17
+ return {
18
+ ...(settings.model === undefined ? {} : { model: parseSelection(settings.model) }),
19
+ ...(settings.timeoutMs === undefined ? {} : { timeoutMs: parseTimeoutMs(settings.timeoutMs) })
20
+ };
21
+ }
22
+ function missing(error: unknown): boolean { return Boolean(error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT'); }
23
+ function safe(error: unknown): never { throw error instanceof AdvisorError ? error : new AdvisorError('settings-unavailable'); }
24
+ async function checkParent(path: string): Promise<void> {
25
+ try { const stat = await lstat(dirname(path)); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new AdvisorError('settings-unavailable'); }
26
+ catch (error) { if (!missing(error)) safe(error); }
27
+ }
28
+ async function readLayer(path: string): Promise<{ settings: Settings; raw?: Buffer }> {
29
+ let handle: FileHandle | undefined;
30
+ try {
31
+ await checkParent(path);
32
+ const stat = await lstat(path);
33
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new AdvisorError('settings-unavailable');
34
+ handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
35
+ const opened = await handle.stat();
36
+ if (!opened.isFile()) throw new AdvisorError('settings-unavailable');
37
+ if (opened.size > MAX_SETTINGS_BYTES) throw new AdvisorError('invalid-config');
38
+ const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
39
+ let length = 0;
40
+ while (length < buffer.length) {
41
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
42
+ if (!bytesRead) break;
43
+ length += bytesRead;
44
+ }
45
+ if (length > MAX_SETTINGS_BYTES) throw new AdvisorError('invalid-config');
46
+ const raw = buffer.subarray(0, length);
47
+ try { return { settings: parseSettings(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(raw))), raw }; }
48
+ catch { throw new AdvisorError('invalid-config'); }
49
+ } catch (error) { if (missing(error)) return { settings: {} }; return safe(error); }
50
+ finally { try { await handle?.close(); } catch (error) { safe(error); } }
51
+ }
52
+ export async function loadSettings(paths: SettingsPaths, trusted: boolean): Promise<LoadedSettings> {
53
+ const global = (await readLayer(paths.global)).settings;
54
+ const project = trusted ? (await readLayer(paths.project)).settings : {};
55
+ const model = project.model ?? global.model, timeoutMs = project.timeoutMs ?? global.timeoutMs;
56
+ return {
57
+ settings: { ...(model ? { model } : {}), ...(timeoutMs === undefined ? {} : { timeoutMs }) },
58
+ ...(model ? { source: project.model ? 'project' as const : 'global' as const } : {}),
59
+ ...(timeoutMs === undefined ? {} : { timeoutSource: project.timeoutMs === undefined ? 'global' as const : 'project' as const })
60
+ };
61
+ }
62
+ export async function saveSettings(paths: SettingsPaths, scope: Scope, value: unknown, trusted: boolean, preserve?: keyof Settings): Promise<void> {
63
+ if (scope === 'project' && !trusted) throw new AdvisorError('settings-unavailable');
64
+ const settings = parseSettings(value);
65
+ const path = paths[scope];
66
+ try {
67
+ await withFileMutationQueue(path, async () => {
68
+ await checkParent(path); await mkdir(dirname(path), { recursive: true }); await checkParent(path);
69
+ const lockPath = path + '.lock', temp = path + '.' + randomUUID() + '.tmp';
70
+ let lock: FileHandle | undefined;
71
+ let ownedTemp = false;
72
+ try {
73
+ lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
74
+ const before = await readLayer(path);
75
+ // Preserve the independent field from the locked checkpoint, not a prior command read.
76
+ const next = { ...(preserve ? { [preserve]: before.settings[preserve] } : {}), ...settings };
77
+ const text = JSON.stringify(next, null, 2) + '\n';
78
+ if (Buffer.byteLength(text) > MAX_SETTINGS_BYTES) throw new AdvisorError('invalid-config');
79
+ const output = await open(temp, 'wx', 0o600); ownedTemp = true;
80
+ try { await output.writeFile(text, 'utf8'); await output.sync(); } finally { await output.close(); }
81
+ const current = await readLayer(path);
82
+ if (before.raw ? !current.raw?.equals(before.raw) : current.raw !== undefined) throw new AdvisorError('settings-unavailable');
83
+ if (before.raw) await rename(temp, path);
84
+ else await link(temp, path);
85
+ } finally {
86
+ try { if (ownedTemp) { try { await unlink(temp); } catch (error) { if (!missing(error)) throw error; } } }
87
+ finally {
88
+ if (lock) {
89
+ try {
90
+ const owned = await lock.stat(), current = await lstat(lockPath);
91
+ if (owned.ino !== current.ino || owned.dev !== current.dev || current.isSymbolicLink()) throw new AdvisorError('settings-unavailable');
92
+ await unlink(lockPath);
93
+ } finally { await lock.close(); }
94
+ }
95
+ }
96
+ }
97
+ });
98
+ } catch (error) { safe(error); }
99
+ }
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@neruok/pi-advisor",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "license": "MIT",
5
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
6
+ "type": "module",
7
+ "description": "Isolated, multi-turn advice for Pi agents",
8
+ "keywords": ["pi-package", "pi-extension", "advisor", "coding-agent"],
9
+ "repository": { "type": "git", "url": "git+https://github.com/neruok/pi-advisor.git" },
10
+ "homepage": "https://github.com/neruok/pi-advisor#readme",
11
+ "bugs": { "url": "https://github.com/neruok/pi-advisor/issues" },
12
+ "pi": { "extensions": ["./advisor.ts"] },
13
+ "files": ["advisor.ts", "lib/*.ts", "README.md", "docs/pi-advisor.md"],
14
+ "engines": { "node": ">=24" },
15
+ "peerDependencies": {
16
+ "@earendil-works/pi-coding-agent": "*",
17
+ "@earendil-works/pi-ai": "*",
18
+ "@earendil-works/pi-tui": "*",
19
+ "typebox": "*"
20
+ },
21
+ "peerDependenciesMeta": {
22
+ "@earendil-works/pi-coding-agent": { "optional": true },
23
+ "@earendil-works/pi-ai": { "optional": true },
24
+ "@earendil-works/pi-tui": { "optional": true },
25
+ "typebox": { "optional": true }
26
+ },
27
+ "devDependencies": {
28
+ "@earendil-works/pi-coding-agent": "1.0.4",
29
+ "@earendil-works/pi-ai": "1.0.4",
30
+ "@earendil-works/pi-tui": "1.0.4",
31
+ "typebox": "1.3.27",
32
+ "typescript": "5.9.3",
33
+ "@types/node": "26.6.4"
34
+ },
35
+ "scripts": {
36
+ "check": "node --test tests/*.test.mjs",
37
+ "typecheck": "tsc --noEmit",
38
+ "verify": "npm run typecheck && npm run check",
39
+ "packcheck": "node scripts/check-package.mjs",
40
+ "prepublishOnly": "npm run verify && npm run packcheck"
41
+ }
42
+ }