@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.
@@ -0,0 +1,88 @@
1
+ import { getAgentDir, type ExtensionContext } from '@earendil-works/pi-coding-agent';
2
+ import { fuzzyFilter, type AutocompleteItem } from '@earendil-works/pi-tui';
3
+ import { REASONING_LEVELS, parseSelection, parseTimeoutMs } from './protocol.ts';
4
+ import { supportedReasoning } from './reasoning.ts';
5
+ import { loadSettings, settingsPaths } from './settings.ts';
6
+
7
+ export type CompletionContext = Pick<ExtensionContext, 'cwd' | 'modelRegistry' | 'isProjectTrusted'>;
8
+ interface Choice { value: string; description: string }
9
+ interface Syntax { base: string; fragment: string; args: string[]; scoped: boolean; project: boolean }
10
+ const commands: Choice[] = [
11
+ { value: 'show', description: 'Show effective advisor settings' },
12
+ { value: 'model', description: 'Set the advisor provider and model' },
13
+ { value: 'reasoning', description: 'Inspect or set advisor reasoning effort' },
14
+ { value: 'timeout', description: 'Inspect or set the advisor deadline in milliseconds' },
15
+ ];
16
+ const scopes: Choice[] = [
17
+ { value: '--global', description: 'Use global settings (default save scope)' },
18
+ { value: '--project', description: 'Use project settings (writes require trust)' },
19
+ ];
20
+ function syntax(prefix: string): Syntax | undefined {
21
+ const fragment = prefix.match(/\S*$/)![0];
22
+ const base = prefix.slice(0, prefix.length - fragment.length);
23
+ const tokens = base.trim() ? base.trim().split(/\s+/) : [];
24
+ const flags = tokens.filter(token => token.startsWith('--'));
25
+ if (flags.length > 1 || flags.some(flag => !scopes.some(choice => choice.value === flag))) return undefined;
26
+ const args = tokens.filter(token => !token.startsWith('--'));
27
+ const [command, argument] = args;
28
+ if (command === 'show') {
29
+ if (args.length > 1) return undefined;
30
+ } else if (command === 'reasoning') {
31
+ if (args.length > 2 || (argument && !REASONING_LEVELS.some(level => level === argument))) return undefined;
32
+ } else if (command === 'timeout') {
33
+ if (args.length > 2) return undefined;
34
+ if (argument && argument !== 'default') {
35
+ if (!/^[0-9]+$/.test(argument)) return undefined;
36
+ try { parseTimeoutMs(Number(argument)); } catch { return undefined; }
37
+ }
38
+ } else if (command === 'model') {
39
+ if (args.length > 3) return undefined;
40
+ } else if (command) return undefined;
41
+ return { base, fragment, args, scoped: flags.length > 0, project: flags[0] === '--project' };
42
+ }
43
+ async function choices(input: Syntax, ctx?: CompletionContext): Promise<Choice[]> {
44
+ const { args, fragment, scoped, project } = input;
45
+ const [command, provider] = args;
46
+ const availableScopes = scoped ? [] : scopes;
47
+ if (fragment.startsWith('--')) return availableScopes;
48
+ if (!command) return [...commands, ...availableScopes];
49
+ if (command === 'show' || ((command === 'reasoning' || command === 'timeout') && args.length === 2) || args.length === 3) return availableScopes;
50
+ if (command === 'timeout') return [{ value: 'default', description: 'Remove the scoped timeout and restore inheritance' }];
51
+ if (!ctx) return [];
52
+ if (command === 'reasoning') {
53
+ if (project && !ctx.isProjectTrusted()) return [];
54
+ const pair = (await loadSettings(settingsPaths(ctx.cwd, getAgentDir()), project)).settings.model;
55
+ if (!pair) return [];
56
+ const model = ctx.modelRegistry.find(pair.provider, pair.model);
57
+ if (!model || model.api === 'pi-virtual') return [];
58
+ return supportedReasoning(model).map(value => ({ value, description: value === 'default' ? 'Keep legacy provider behavior' : `Set advisor reasoning to ${value}` }));
59
+ }
60
+ const models = ctx.modelRegistry.getAvailable().filter(model => model.api !== 'pi-virtual');
61
+ if (!provider) {
62
+ return [...new Set(models.map(model => model.provider))].sort((a, b) => a.localeCompare(b))
63
+ .filter(value => value.toLowerCase().startsWith(fragment.toLowerCase()))
64
+ .map(value => ({ value, description: 'Available advisor provider' }));
65
+ }
66
+ const candidates = models.filter(model => model.provider === provider).sort((a, b) => a.id.localeCompare(b.id));
67
+ return fuzzyFilter(candidates, fragment, model => `${model.id} ${model.name ?? ''}`)
68
+ .map(model => ({ value: model.id, description: model.name || 'Available advisor model' }));
69
+ }
70
+ export async function argumentCompletions(prefix: string, ctx?: CompletionContext): Promise<AutocompleteItem[] | null> {
71
+ const input = syntax(prefix);
72
+ if (!input) return null;
73
+ try {
74
+ const catalogArgument = input.args[0] === 'model' && input.args.length < 3 && !input.fragment.startsWith('--');
75
+ const candidates = await choices(input, ctx);
76
+ const matched = catalogArgument ? candidates : candidates.filter(choice => choice.value.startsWith(input.fragment));
77
+ const items = matched.filter(choice => {
78
+ if (!catalogArgument) return true;
79
+ if (choice.value.startsWith('--')) return false;
80
+ try { parseSelection({ provider: input.args[1] ?? choice.value, model: input.args[1] ? choice.value : 'placeholder' }); return true; }
81
+ catch { return false; }
82
+ }).map(choice => ({ value: `${input.base}${choice.value} `, label: choice.value, description: choice.description }));
83
+ return items.length ? items : null;
84
+ } catch {
85
+ // Completion failures stay silent and never trigger a retry or write.
86
+ return null;
87
+ }
88
+ }
package/lib/context.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { Context } from '@earendil-works/pi-ai';
2
+ import { calculateContextTokens, estimateTokens } from '@earendil-works/pi-coding-agent';
3
+
4
+ export type ContextUsage = { tokens: number; contextWindow: number; percent: number };
5
+ // Match Pi's usage-backed context estimate. Ciphertext bytes are not model tokens.
6
+ export function contextUsage(messages: Context['messages'], contextWindow: number): ContextUsage {
7
+ let trailing = 0;
8
+ for (let i = messages.length - 1; i >= 0; i--) {
9
+ const message = messages[i];
10
+ if (message.role === 'assistant' && message.stopReason !== 'error' && message.stopReason !== 'aborted') {
11
+ const reported = calculateContextTokens(message.usage);
12
+ if (reported > 0) return { tokens: reported + trailing, contextWindow, percent: (reported + trailing) / contextWindow * 100 };
13
+ }
14
+ trailing += estimateTokens(message);
15
+ }
16
+ return { tokens: trailing, contextWindow, percent: trailing / contextWindow * 100 };
17
+ }
@@ -0,0 +1,85 @@
1
+ import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
2
+ import { fuzzyFilter, getKeybindings, Input, SelectList, truncateToWidth } from '@earendil-works/pi-tui';
3
+ import { AdvisorError, type Selection } from './protocol.ts';
4
+
5
+ type PickerModel = { provider: string; id: string; name?: string };
6
+ const key = (model: PickerModel) => `${model.provider} / ${model.id}`;
7
+
8
+ /** Copy pi-magic8ball's /model-style inline picker without its decision dependencies. */
9
+ export async function selectModel(ctx: ExtensionCommandContext, title: string, models: readonly PickerModel[], current?: Selection): Promise<Selection | undefined> {
10
+ const entries = new Map(models.map(model => [key(model), model]));
11
+ if (ctx.mode !== 'tui') {
12
+ // Preserve advisor's existing non-TUI labels.
13
+ const choices = new Map(models.map(model => [`${model.provider}/${model.id}`, model]));
14
+ const selected = await ctx.ui.select(title, [...choices.keys()]);
15
+ if (selected === undefined) return undefined;
16
+ const model = choices.get(selected);
17
+ if (!model) throw new AdvisorError('model-unavailable');
18
+ return { provider: model.provider, model: model.id };
19
+ }
20
+ return ctx.ui.custom<Selection | undefined>((tui, theme, _keybindings, done) => {
21
+ const isCurrent = (model: PickerModel) => model.provider === current?.provider && model.id === current.model;
22
+ const items = [...entries.values()]
23
+ .sort((a, b) => Number(isCurrent(b)) - Number(isCurrent(a)) || a.provider.localeCompare(b.provider))
24
+ .map(model => ({ value: key(model), label: `${isCurrent(model) ? '✓ ' : ' '}${model.id} [${model.provider}]`, model }));
25
+ const search = new Input();
26
+ const style = {
27
+ selectedPrefix: (text: string) => theme.fg('accent', text),
28
+ selectedText: (text: string) => theme.fg('accent', text),
29
+ description: (text: string) => theme.fg('muted', text),
30
+ scrollInfo: (text: string) => theme.fg('muted', text),
31
+ noMatch: () => theme.fg('muted', ' No matching models'),
32
+ };
33
+ let filtered = items, selectedIndex = 0, visible = 10;
34
+ function makeList(): SelectList {
35
+ const next = new SelectList(filtered, visible, style);
36
+ next.setSelectedIndex(selectedIndex);
37
+ next.onSelectionChange = item => { selectedIndex = filtered.findIndex(entry => entry.value === item.value); };
38
+ next.onSelect = item => {
39
+ const model = entries.get(item.value)!;
40
+ done({ provider: model.provider, model: model.id });
41
+ };
42
+ next.onCancel = () => done(undefined);
43
+ return next;
44
+ }
45
+ let list = makeList();
46
+ search.onSubmit = () => list.handleInput('\r');
47
+ function filter(query: string): void {
48
+ // Same search text order and token ranking as Pi's /model selector.
49
+ filtered = fuzzyFilter(items, query, ({ model }) => `${model.provider} ${model.provider}/${model.id} ${model.provider} ${model.id}${model.name ? ` ${model.name}` : ''}`);
50
+ selectedIndex = query ? 0 : Math.min(selectedIndex, Math.max(0, filtered.length - 1));
51
+ list = makeList();
52
+ }
53
+ return {
54
+ get focused() { return search.focused; },
55
+ set focused(value: boolean) { search.focused = value; },
56
+ render(width: number) {
57
+ const height = Math.max(1, tui.terminal.rows - 2);
58
+ const full = height >= 10;
59
+ const border = theme.fg('accent', '─'.repeat(Math.max(0, width)));
60
+ const heading = theme.fg('accent', theme.bold(title));
61
+ const prefix = full ? [border, heading, ...search.render(width), '']
62
+ : height >= 4 ? [heading, ...search.render(width)] : height >= 2 ? search.render(width) : [];
63
+ const selected = filtered[selectedIndex]?.model;
64
+ const suffix = full ? ['', theme.fg('muted', ` Model Name: ${selected?.name ?? selected?.id ?? ''}`), theme.fg('dim', '↑↓ navigate • enter select • esc cancel'), border] : [];
65
+ const listHeight = height - prefix.length - suffix.length;
66
+ // Count SelectList's position indicator within the viewport budget.
67
+ const count = Math.max(1, Math.min(10, filtered.length, listHeight - (filtered.length > Math.min(10, listHeight) ? 1 : 0)));
68
+ if (count !== visible) { visible = count; list = makeList(); }
69
+ return [...prefix, ...list.render(width).slice(0, listHeight), ...suffix].map(line => truncateToWidth(line, width));
70
+ },
71
+ invalidate() { search.invalidate(); list.invalidate(); },
72
+ handleInput(data: string) {
73
+ const kb = getKeybindings();
74
+ if (kb.matches(data, 'tui.select.up') || kb.matches(data, 'tui.select.down') || kb.matches(data, 'tui.select.confirm') || kb.matches(data, 'tui.select.cancel')) {
75
+ list.handleInput(data);
76
+ } else {
77
+ const before = search.getValue();
78
+ search.handleInput(data);
79
+ if (search.getValue() !== before) filter(search.getValue());
80
+ }
81
+ tui.requestRender();
82
+ },
83
+ };
84
+ });
85
+ }
package/lib/prompt.ts ADDED
@@ -0,0 +1,21 @@
1
+ export const ADVISOR_PROMPT = `You are an advisor to another software-engineering agent.
2
+ You have no repository, filesystem, shell, tools, parent conversation, or runtime access.
3
+ Only this consultation's explicit messages describe the current system.
4
+ Distinguish reported observations and test results from interpretations, assumptions, and hypotheses.
5
+ Reported evidence is not independently verified by you. Do not invent repository facts.
6
+ Ask the main agent to inspect, test, measure, or clarify missing evidence.
7
+ Prefer investigations that distinguish competing explanations. Challenge unsupported framing.
8
+ Help the main agent reach a next action or identify a user-owned decision.
9
+ Your advice is not evidence or authorization. The main agent owns verification and action.
10
+ Do not grant approval for product, policy, architecture, spending, or destructive choices.
11
+ Do not repeat private reasoning. Give a concise explanation and concrete questions or recommendations.
12
+ Reply with natural-language advice. Ask for missing evidence or describe a user-owned decision when needed.`;
13
+
14
+ export const MAIN_GUIDANCE = [
15
+ 'Use advisor for independent reasoning about debugging, design, or diagnosis. It cannot inspect code or inherit this conversation.',
16
+ 'Explain your objective, current understanding, observations, evidence, constraints, and uncertainty. Do not substitute a code dump for an explanation.',
17
+ 'Continue the same session for the same issue. Gather requested evidence yourself and return your findings.',
18
+ 'Advisor claims are not evidence or authorization. Verify factual claims and obtain user approval when required before acting.',
19
+ 'Use advisor_sessions to recover active identifiers and advisor_close when finished.',
20
+ 'Consultations are ephemeral. Parent transcript arguments/results can still persist. Configured provider calls may incur charges.'
21
+ ];
@@ -0,0 +1,131 @@
1
+ import type { AssistantMessage, Usage } from '@earendil-works/pi-ai';
2
+ import type { ContextUsage } from './context.ts';
3
+
4
+ export const LIMITS = Object.freeze({ timeoutMs: 300000 });
5
+ export const MAX_TIMEOUT_MS = 2147483647;
6
+ export const REASONING_LEVELS = ['default', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
7
+ export type Reasoning = typeof REASONING_LEVELS[number];
8
+ export type Selection = { provider: string; model: string; reasoning?: Reasoning };
9
+ export type ErrorCode = 'invalid-argument' | 'not-found' | 'busy' | 'limit-exceeded' | 'not-configured' | 'invalid-config' | 'settings-unavailable' | 'model-unavailable' | 'unsupported-reasoning' | 'provider-failed' | 'invalid-response' | 'cancelled' | 'timeout';
10
+ const ERRORS: Record<ErrorCode, string> = {
11
+ 'invalid-argument': 'Use a nonblank message and an existing session identifier, if supplied.',
12
+ 'not-found': 'Consultation not found. It may have closed or its parent session may have changed.',
13
+ busy: 'Consultation has a pending request. Wait for it before sending or closing.',
14
+ 'limit-exceeded': 'Advisor context limit exceeded. Start a new consultation with an explicit summary.',
15
+ 'not-configured': 'No advisor model configured. Use /advisor model <provider> <model>.',
16
+ 'invalid-config': 'Advisor settings must be strict JSON with an optional model selection and integer timeoutMs.',
17
+ 'settings-unavailable': 'Cannot safely read or save advisor settings. Inspect settings and locks before retrying.',
18
+ 'model-unavailable': 'Configured advisor model is unavailable, virtual, or lacks authentication.',
19
+ 'unsupported-reasoning': 'Configured advisor reasoning is not supported by the selected model. Use /advisor reasoning to inspect supported levels.',
20
+ 'provider-failed': 'Advisor provider request failed. No exchange was committed.',
21
+ 'invalid-response': 'Advisor reply was not a usable text completion. No exchange was committed.',
22
+ cancelled: 'Advisor request cancelled. No exchange was committed.',
23
+ timeout: 'Advisor request deadline exceeded. No exchange was committed.'
24
+ };
25
+ export type LimitResource = 'context-tokens';
26
+ export type LimitDetails = { resource: LimitResource; maximum: number; actual: number };
27
+ const LIMIT_MESSAGES: Record<LimitResource, string> = {
28
+ 'context-tokens': 'Advisor context limit exceeded. Start a new consultation with an explicit summary.'
29
+ };
30
+ const REPLY_MESSAGES = {
31
+ completion: 'Advisor completion did not stop normally.',
32
+ content: 'Advisor reply contained unsupported or malformed content.',
33
+ text: 'Advisor reply contained no nonblank text.'
34
+ } as const;
35
+ export class AdvisorError extends Error {
36
+ readonly code: ErrorCode;
37
+ readonly limit?: LimitDetails;
38
+ constructor(code: ErrorCode, limit?: LimitDetails, replyReason?: keyof typeof REPLY_MESSAGES) {
39
+ super(replyReason && code === 'invalid-response'
40
+ ? REPLY_MESSAGES[replyReason] + ' No exchange was committed.'
41
+ : limit && code === 'limit-exceeded' ? LIMIT_MESSAGES[limit.resource] : ERRORS[code]);
42
+ this.name = 'AdvisorError'; this.code = code; this.limit = limit;
43
+ }
44
+ }
45
+ export function parseTimeoutMs(value: unknown, code: 'invalid-config' | 'invalid-argument' = 'invalid-config'): number {
46
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > MAX_TIMEOUT_MS) throw new AdvisorError(code);
47
+ return value;
48
+ }
49
+ export function checkLimit(resource: LimitResource, maximum: number, actual: number): void {
50
+ if (actual > maximum) throw new AdvisorError('limit-exceeded', { resource, maximum, actual });
51
+ }
52
+ export function zeroUsage(): Usage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }; }
53
+ export function addUsage(a: Usage, b: Usage): Usage {
54
+ const result = zeroUsage();
55
+ for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'totalTokens'] as const) result[key] = a[key] + b[key];
56
+ for (const key of ['input', 'output', 'cacheRead', 'cacheWrite', 'total'] as const) result.cost[key] = a.cost[key] + b.cost[key];
57
+ for (const key of ['reasoning', 'cacheWrite1h'] as const) if (a[key] !== undefined || b[key] !== undefined) result[key] = (a[key] ?? 0) + (b[key] ?? 0);
58
+ return result;
59
+ }
60
+ export const FAILURE_PHASES = ['validation', 'preparation', 'completion', 'response-validation', 'commit'] as const;
61
+ export const FAILURE_CATEGORIES = ['advisor-error', 'local-error', 'unknown', 'provider-error', 'provider-aborted', 'authentication', 'provider-rejection', 'rate-limit', 'transport', 'sdk-error'] as const;
62
+ export type SelectionSource = 'global' | 'project' | 'unknown';
63
+ export type Diagnostics = { phase: typeof FAILURE_PHASES[number]; category: typeof FAILURE_CATEGORIES[number]; model?: Selection; selectionSource?: SelectionSource; code?: 'sdk-invalid-timeout' | 'tool-choice-without-tools'; httpStatus?: number };
64
+ // Only recognized structured fields are used. Never inspect messages, causes, headers or payloads.
65
+ export function completionCategory(error: unknown): Diagnostics['category'] {
66
+ try {
67
+ if (!error || typeof error !== 'object') return 'unknown';
68
+ const status = Object.getOwnPropertyDescriptor(error, 'status')?.value;
69
+ if (status === 401 || status === 403) return 'authentication';
70
+ if (status === 429) return 'rate-limit';
71
+ if ([400, 404, 409, 413, 422].includes(status)) return 'provider-rejection';
72
+ if ([408, 500, 502, 503, 504].includes(status)) return 'transport';
73
+ const code = Object.getOwnPropertyDescriptor(error, 'code')?.value;
74
+ if (['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'].includes(code)) return 'transport';
75
+ } catch { /* Unknown diagnostic metadata must not replace the original failure. */ }
76
+ return 'unknown';
77
+ }
78
+ // Read only fixed SDK diagnostics and an exact known xAI rejection. Never return the provider body.
79
+ export function terminalDiagnostics(reply: AssistantMessage, provider: string): Pick<Diagnostics, 'category' | 'code' | 'httpStatus'> {
80
+ const fallback = { category: 'provider-error' as const };
81
+ try {
82
+ const message = Object.getOwnPropertyDescriptor(reply, 'errorMessage')?.value;
83
+ if (typeof message !== 'string') return fallback;
84
+ if (message === 'timeout must be an integer' || message === 'timeout must be a positive integer') {
85
+ return { category: 'sdk-error', code: 'sdk-invalid-timeout' };
86
+ }
87
+ const prefix = `${provider === 'openai' ? 'OpenAI' : provider} API error (`;
88
+ if (!message.startsWith(prefix)) return fallback;
89
+ const match = /^([45][0-9]{2})\): /.exec(message.slice(prefix.length, prefix.length + 7));
90
+ if (!match) return fallback;
91
+ const httpStatus = Number(match[1]);
92
+ const category = completionCategory({ status: httpStatus });
93
+ const rejection = 'Invalid request content: A tool_choice was set on the request but no tools were specified.';
94
+ const detail = message.slice(prefix.length + match[0].length);
95
+ const noTools = provider === 'xai' && httpStatus === 400 && [rejection, rejection.slice('Invalid request content: '.length), `400 ${rejection}`].includes(detail);
96
+ return { category: category === 'unknown' ? 'provider-error' : category, httpStatus, ...(noTools ? { code: 'tool-choice-without-tools' as const } : {}) };
97
+ } catch { return fallback; }
98
+ }
99
+ export type UsageTotals = { totalUsage: Usage; totalUsageComplete: boolean };
100
+ export type Failure = { ok: false; error: { code: ErrorCode; message: string; limit?: LimitDetails; diagnostics?: Diagnostics }; usage: Usage; usageComplete: boolean; session?: string; contextUsage?: ContextUsage } & Partial<UsageTotals>;
101
+ export function failure(error: unknown, usage = zeroUsage(), session?: string, usageComplete = true): Failure {
102
+ const safe = error instanceof AdvisorError ? error : new AdvisorError('provider-failed');
103
+ return { ok: false, error: { code: safe.code, message: safe.message, ...(safe.limit ? { limit: { ...safe.limit } } : {}) }, usage, usageComplete, ...(session === undefined ? {} : { session }) };
104
+ }
105
+ export function object(value: unknown, keys: string[], code: ErrorCode): Record<string, unknown> {
106
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) throw new AdvisorError(code);
107
+ return value as Record<string, unknown>;
108
+ }
109
+ export function parseSelection(value: unknown): Selection {
110
+ const pair = object(value, ['provider', 'model', 'reasoning'], 'invalid-config');
111
+ for (const value of [pair.provider, pair.model]) if (typeof value !== 'string' || !value || /[\s\x00-\x1f\x7f-\x9f]/u.test(value)) throw new AdvisorError('invalid-config');
112
+ if (pair.reasoning !== undefined && !REASONING_LEVELS.includes(pair.reasoning as Reasoning)) throw new AdvisorError('invalid-config');
113
+ return { provider: pair.provider as string, model: pair.model as string, ...(pair.reasoning === undefined ? {} : { reasoning: pair.reasoning as Reasoning }) };
114
+ }
115
+ export function parseInput(value: unknown): { message: string; session?: string; diagnostics?: boolean } {
116
+ const input = object(value, ['message', 'session', 'diagnostics'], 'invalid-argument');
117
+ if (input.diagnostics !== undefined && typeof input.diagnostics !== 'boolean') throw new AdvisorError('invalid-argument');
118
+ if (typeof input.message !== 'string' || !input.message.trim() || (input.session !== undefined && (typeof input.session !== 'string' || !input.session.trim()))) throw new AdvisorError('invalid-argument');
119
+ return { message: input.message, ...(input.session === undefined ? {} : { session: input.session as string }), ...(input.diagnostics === undefined ? {} : { diagnostics: input.diagnostics as boolean }) };
120
+ }
121
+ export function replyText(message: AssistantMessage): string {
122
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') throw new AdvisorError('provider-failed');
123
+ if (message.stopReason !== 'stop') throw new AdvisorError('invalid-response', undefined, 'completion');
124
+ if (!Array.isArray(message.content) || message.content.some(block => !block ||
125
+ (block.type !== 'text' && block.type !== 'thinking') || (block.type === 'text' && typeof block.text !== 'string'))) {
126
+ throw new AdvisorError('invalid-response', undefined, 'content');
127
+ }
128
+ const text = message.content.filter(block => block.type === 'text').map(block => block.text).join('\n');
129
+ if (!text.trim()) throw new AdvisorError('invalid-response', undefined, 'text');
130
+ return text;
131
+ }
@@ -0,0 +1,9 @@
1
+ import { getSupportedThinkingLevels, type Api, type Model } from '@earendil-works/pi-ai';
2
+ import { AdvisorError, type Reasoning } from './protocol.ts';
3
+
4
+ export function supportedReasoning(model: Model<Api>): Reasoning[] {
5
+ return ['default', ...getSupportedThinkingLevels(model)];
6
+ }
7
+ export function assertReasoning(model: Model<Api>, level?: Reasoning): void {
8
+ if (level !== undefined && !supportedReasoning(model).includes(level)) throw new AdvisorError('unsupported-reasoning');
9
+ }
package/lib/render.ts ADDED
@@ -0,0 +1,121 @@
1
+ import type { Theme, ToolRenderResultOptions } from '@earendil-works/pi-coding-agent';
2
+ import { truncateToWidth, wrapTextWithAnsi, type Component } from '@earendil-works/pi-tui';
3
+ import type { Usage } from '@earendil-works/pi-ai';
4
+ import type { Advice, Metadata, Phase } from './session.ts';
5
+ import type { Failure } from './protocol.ts';
6
+ import type { ContextUsage } from './context.ts';
7
+
8
+ const PREVIEW_ROWS = 8;
9
+ type Kind = 'advisor' | 'advisor_sessions' | 'advisor_close';
10
+ type Data = Advice | Failure | { ok: true; sessions: Metadata[] } | { ok: true; session: string };
11
+ const safe = (text: string): string => text.replace(/\t/g, ' ').replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/gu, '');
12
+ const value = (text: unknown): string => typeof text === 'string' ? safe(text) : '?';
13
+ const label = (text: unknown): string => value(text).replace(/\n/g, ' ');
14
+ function component(lines: (width: number) => string[]): Component {
15
+ return { render: width => width < 1 ? [] : lines(width).map(line => truncateToWidth(line, width)), invalidate() {} };
16
+ }
17
+ function usageLine(usage: Usage | undefined, complete: boolean | undefined): string {
18
+ const tokens = usage?.totalTokens ?? 0, cost = usage?.cost?.total;
19
+ return `${tokens} reported tokens${typeof cost === 'number' && Number.isFinite(cost) ? `, $${cost.toFixed(6)} reported cost` : ''}${complete === true ? '' : ' (incomplete: remote usage may be missing)'}`;
20
+ }
21
+ // Pi 1.0.4 footer thresholds, without subscription or auto-compaction labels.
22
+ function formatTokens(count: number | undefined): string {
23
+ if (typeof count !== 'number' || !Number.isFinite(count)) return '?';
24
+ if (count < 1000) return `${count}`;
25
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
26
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
27
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
28
+ return `${Math.round(count / 1000000)}M`;
29
+ }
30
+ function contextLine(context: ContextUsage | undefined): string {
31
+ return context ? `${context.percent.toFixed(1)}%/${formatTokens(context.contextWindow)}` : '?/?';
32
+ }
33
+ function statusLine(data: Advice | Failure): string {
34
+ const total = data.totalUsage ?? data.usage, latest = data.usage;
35
+ const input = latest.input + latest.cacheRead + latest.cacheWrite;
36
+ const hit = data.usageComplete && input > 0 ? `${(latest.cacheRead / input * 100).toFixed(1)}%` : '?';
37
+ const parts = [`↑${formatTokens(total.input)}`, `↓${formatTokens(total.output)}`, `R${formatTokens(total.cacheRead)}`];
38
+ if (total.cacheWrite) parts.push(`W${formatTokens(total.cacheWrite)}`);
39
+ parts.push(`CH${hit}`, Number.isFinite(total.cost.total) ? `$${total.cost.total.toFixed(3)}` : '$?', contextLine(data.contextUsage));
40
+ if (!(data.totalUsageComplete ?? data.usageComplete)) parts.push('(incomplete: remote usage may be missing)');
41
+ return parts.join(' ');
42
+ }
43
+ function header(data: Advice): string {
44
+ return `Advisory • ${label(data.session)} • turn ${data.turns}`;
45
+ }
46
+ function sessionLines(entry: Metadata, expanded: boolean): string[] {
47
+ return [
48
+ `${label(entry.session)}${entry.busy ? ' • busy' : ''} • ${entry.turns} exchanges`,
49
+ ...(expanded ? [label(entry.label), `${label(entry.model?.provider)}/${label(entry.model?.model)} • ${contextLine(entry.contextUsage)} advisor context`, usageLine(entry.totalUsage, entry.totalUsageComplete)] : [])
50
+ ];
51
+ }
52
+ function details(value: unknown): Data | undefined {
53
+ if (!value || typeof value !== 'object' || !('ok' in value)) return undefined;
54
+ const data = value as Data;
55
+ if (data.ok === false) return data.error && typeof data.error.code === 'string' && typeof data.error.message === 'string' ? data : undefined;
56
+ if (data.ok !== true) return undefined;
57
+ if ('response' in data) return typeof data.response === 'string' && data.model && typeof data.session === 'string' ? data : undefined;
58
+ if ('sessions' in data) return Array.isArray(data.sessions) ? data : undefined;
59
+ return 'session' in data && typeof data.session === 'string' ? data : undefined;
60
+ }
61
+ function resultLines(kind: Kind, data: Data | undefined, options: ToolRenderResultOptions, theme: Theme, width: number): string[] {
62
+ const styled = (color: 'muted' | 'error' | 'toolOutput' | 'accent', text: string) => wrapTextWithAnsi(theme.fg(color, safe(text)), width);
63
+ if (!data) return styled('muted', 'Advisor result details unavailable.');
64
+ if (!data.ok) {
65
+ const lines = [theme.fg('error', safe(`${label(data.error.code)}: ${value(data.error.message)}`))];
66
+ if (data.error.limit) lines.push(theme.fg('muted', safe(`${data.error.limit.resource}: ${data.error.limit.actual} / ${data.error.limit.maximum}`)));
67
+ if (data.error.diagnostics) {
68
+ const diagnostic = data.error.diagnostics;
69
+ lines.push(theme.fg('muted', safe(`Failure phase: ${label(diagnostic.phase)} • category: ${label(diagnostic.category)}`)));
70
+ if (diagnostic.code === 'sdk-invalid-timeout') lines.push(theme.fg('error', 'SDK timeout must be a positive integer. No exchange was committed.'));
71
+ if (diagnostic.code === 'tool-choice-without-tools') lines.push(theme.fg('error', 'xAI rejected tool_choice without tools. No exchange was committed.'));
72
+ if (diagnostic.httpStatus !== undefined) lines.push(theme.fg('muted', safe(`HTTP status: ${diagnostic.httpStatus}`)));
73
+ if (diagnostic.model) lines.push(theme.fg('muted', safe(`${label(diagnostic.model.provider)}/${label(diagnostic.model.model)} • ${label(diagnostic.selectionSource)} • reasoning: ${label(diagnostic.model.reasoning ?? 'default')}`)));
74
+ }
75
+ if (data.totalUsage) lines.push(theme.fg('muted', statusLine(data)));
76
+ lines.push(theme.fg('muted', usageLine(data.usage, data.usageComplete)));
77
+ if (options.expanded && data.totalUsage) lines.push(theme.fg('muted', `Consultation total: ${usageLine(data.totalUsage, data.totalUsageComplete)}`));
78
+ return lines.flatMap(line => wrapTextWithAnsi(line, width));
79
+ }
80
+ if (kind === 'advisor' && 'response' in data) {
81
+ const lines = [...styled('accent', header(data)), ...styled('muted', statusLine(data))];
82
+ if (options.expanded) {
83
+ lines.push(...styled('muted', usageLine(data.usage, data.usageComplete)));
84
+ lines.push(...styled('muted', `${label(data.model.provider)}/${label(data.model.model)}`));
85
+ lines.push(...styled('muted', `Reasoning: ${data.model.reasoning ?? 'default (legacy provider behavior)'}`));
86
+ lines.push(...styled('muted', `Consultation total: ${usageLine(data.totalUsage, data.totalUsageComplete)}`));
87
+ }
88
+ const adviceRows = styled('toolOutput', value(data.response));
89
+ const preview = !options.expanded && adviceRows.length > PREVIEW_ROWS;
90
+ lines.push(...(preview ? adviceRows.slice(0, PREVIEW_ROWS) : adviceRows));
91
+ if (preview) lines.push(...styled('muted', '… Expand for full advice.'));
92
+ return lines;
93
+ }
94
+ if (kind === 'advisor_sessions' && 'sessions' in data) {
95
+ return [...styled('muted', `${data.sessions.length} active advisor consultations`), ...data.sessions.flatMap(entry => sessionLines(entry, options.expanded).flatMap(line => styled('toolOutput', line)))];
96
+ }
97
+ if (kind === 'advisor_close' && 'session' in data) return styled('muted', `Advisor consultation closed: ${label(data.session)}. Parent transcript remains.`);
98
+ return styled('muted', 'Advisor result details unavailable.');
99
+ }
100
+ export function toolRenderers(kind: Kind) {
101
+ return {
102
+ renderCall(args: object, theme: Theme): Component {
103
+ const session = 'session' in args ? args.session : undefined;
104
+ return component(width => [theme.fg('toolTitle', theme.bold(kind === 'advisor'
105
+ ? `Advisor • ${typeof session === 'string' ? `continue ${label(session)}` : 'new consultation'}`
106
+ : kind === 'advisor_sessions' ? 'Advisor • active consultations' : `Advisor • close ${label(session)}`)),
107
+ ...(kind === 'advisor' && 'message' in args && typeof args.message === 'string'
108
+ ? wrapTextWithAnsi(theme.fg('toolOutput', `Agent → advisor:\n${safe(args.message)}`), width) : [])]);
109
+ },
110
+ renderResult(result: { details?: unknown }, options: ToolRenderResultOptions, theme: Theme): Component {
111
+ return component(width => {
112
+ if (options.isPartial) {
113
+ const phase = (result.details as { phase?: Phase } | undefined)?.phase;
114
+ const text = phase === 'preparing' ? 'Advisor: preparing model…' : phase === 'waiting' ? 'Advisor: waiting for validated reply…' : 'Advisor request pending…';
115
+ return wrapTextWithAnsi(theme.fg('muted', text), width);
116
+ }
117
+ return resultLines(kind, details(result.details), options, theme, width);
118
+ });
119
+ }
120
+ };
121
+ }
package/lib/replay.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { AssistantMessage, TextContent, ThinkingContent } from '@earendil-works/pi-ai';
2
+ import type { Selection } from './protocol.ts';
3
+
4
+ export type Replay = { api: AssistantMessage['api']; content: (TextContent | ThinkingContent)[] };
5
+ const responsesApis = new Set(['openai-responses', 'openai-codex-responses', 'azure-openai-responses']);
6
+ const googleApis = new Set(['google-generative-ai', 'google-vertex']);
7
+ const redactedApis = new Set(['anthropic-messages', 'bedrock-converse-stream']);
8
+ // Optional metadata must not execute accessors, retain arbitrary fields, or invalidate advice.
9
+ function own(value: unknown, key: string): unknown {
10
+ if (!value || typeof value !== 'object') return undefined;
11
+ try { return Object.getOwnPropertyDescriptor(value, key)?.value; } catch { return undefined; }
12
+ }
13
+ function nonempty(value: unknown): value is string { return typeof value === 'string' && value.length > 0; }
14
+ function parsed(value: unknown): unknown {
15
+ if (typeof value !== 'string') return undefined;
16
+ try { return JSON.parse(value); } catch { return undefined; }
17
+ }
18
+ function reasoningSignature(value: unknown): string | undefined {
19
+ const item = parsed(value), id = own(item, 'id'), encrypted = own(item, 'encrypted_content');
20
+ if (own(item, 'type') !== 'reasoning' || !nonempty(id) || !nonempty(encrypted)) return undefined;
21
+ const status = own(item, 'status');
22
+ return JSON.stringify({ type: 'reasoning', id, encrypted_content: encrypted, summary: [],
23
+ ...(['completed', 'incomplete', 'in_progress'].includes(status as string) ? { status } : {}) });
24
+ }
25
+ function textSignature(value: unknown): string | undefined {
26
+ const signature = parsed(value), id = own(signature, 'id'), phase = own(signature, 'phase');
27
+ if (own(signature, 'v') !== 1 || typeof id !== 'string') return undefined;
28
+ return JSON.stringify({ v: 1, id, ...(phase === 'commentary' || phase === 'final_answer' ? { phase } : {}) });
29
+ }
30
+ function googleSignature(value: unknown): string | undefined {
31
+ return nonempty(value) && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value) ? value : undefined;
32
+ }
33
+
34
+ // Called only after replyText validates the completion. Never keep the original reply.
35
+ export function replay(message: AssistantMessage, model: Selection): Replay | undefined {
36
+ const api = own(message, 'api');
37
+ if (own(message, 'provider') !== model.provider || own(message, 'model') !== model.model || typeof api !== 'string') return undefined;
38
+ const responses = responsesApis.has(api), google = googleApis.has(api);
39
+ if (!responses && !google && !redactedApis.has(api)) return undefined;
40
+ const content: Replay['content'] = [];
41
+ for (const block of message.content) {
42
+ if (block.type === 'text') {
43
+ const text = own(block, 'text');
44
+ if (typeof text !== 'string') return undefined;
45
+ const signature = responses ? textSignature(own(block, 'textSignature')) : google ? googleSignature(own(block, 'textSignature')) : undefined;
46
+ content.push({ type: 'text', text, ...(signature ? { textSignature: signature } : {}) });
47
+ } else {
48
+ const signature = responses ? reasoningSignature(own(block, 'thinkingSignature'))
49
+ : google ? googleSignature(own(block, 'thinkingSignature'))
50
+ : own(block, 'redacted') === true && nonempty(own(block, 'thinkingSignature')) ? own(block, 'thinkingSignature') as string : undefined;
51
+ if (signature) content.push({ type: 'thinking', thinking: '', thinkingSignature: signature, ...(!responses && !google ? { redacted: true } : {}) });
52
+ }
53
+ }
54
+ return { api, content };
55
+ }
package/lib/schemas.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { Type } from 'typebox';
2
+ import { FAILURE_PHASES, FAILURE_CATEGORIES, REASONING_LEVELS } from './protocol.ts';
3
+ const text = Type.String({ minLength: 1 });
4
+ const object = { additionalProperties: false };
5
+ export const ModelSchema = Type.Object({ provider: text, model: text, reasoning: Type.Optional(Type.Union(REASONING_LEVELS.map(level => Type.Literal(level)))) }, object);
6
+ export const UsageSchema = Type.Object({ input: Type.Number(), output: Type.Number(), cacheRead: Type.Number(), cacheWrite: Type.Number(), totalTokens: Type.Number(), reasoning: Type.Optional(Type.Number()), cacheWrite1h: Type.Optional(Type.Number()), cost: Type.Object({ input: Type.Number(), output: Type.Number(), cacheRead: Type.Number(), cacheWrite: Type.Number(), total: Type.Number() }, object) }, object);
7
+ const totals = { totalUsage: UsageSchema, totalUsageComplete: Type.Boolean() };
8
+ export const ContextUsageSchema = Type.Object({ tokens: Type.Number({ minimum: 0 }), contextWindow: Type.Integer({ minimum: 1 }), percent: Type.Number({ minimum: 0 }) }, object);
9
+ const LimitSchema = Type.Object({
10
+ resource: Type.Literal('context-tokens'),
11
+ maximum: Type.Integer({ minimum: 0 }), actual: Type.Integer({ minimum: 0 })
12
+ }, object);
13
+ export const DiagnosticsSchema = Type.Object({
14
+ phase: Type.Union(FAILURE_PHASES.map(s => Type.Literal(s))),
15
+ category: Type.Union(FAILURE_CATEGORIES.map(s => Type.Literal(s))),
16
+ code: Type.Optional(Type.Union([Type.Literal('sdk-invalid-timeout'), Type.Literal('tool-choice-without-tools')])),
17
+ httpStatus: Type.Optional(Type.Integer({ minimum: 400, maximum: 599 })),
18
+ model: Type.Optional(ModelSchema),
19
+ selectionSource: Type.Optional(Type.Union(['global', 'project', 'unknown'].map(s => Type.Literal(s))))
20
+ }, object);
21
+ const failure = {
22
+ ok: Type.Literal(false), error: Type.Object({ code: text, message: text, limit: Type.Optional(LimitSchema), diagnostics: Type.Optional(DiagnosticsSchema) }, object),
23
+ usage: UsageSchema, usageComplete: Type.Boolean(), session: Type.Optional(text), contextUsage: Type.Optional(ContextUsageSchema)
24
+ };
25
+ // Cumulative usage and its completeness indicator must appear together.
26
+ export const FailureSchema = Type.Union([Type.Object(failure, object), Type.Object({ ...failure, ...totals }, object)]);
27
+ export const InputSchema = Type.Object({ message: text, session: Type.Optional(text), diagnostics: Type.Optional(Type.Boolean({ description: 'Opt in for safe failure phase, category and selected model metadata. No raw provider errors. Does not retry.' })) }, object);
28
+ export const CloseInputSchema = Type.Object({ session: text }, object);
29
+ export const EmptyInputSchema = Type.Object({}, object);
30
+ export const AdviceSchema = Type.Union([Type.Object({ ok: Type.Literal(true), advisory: Type.Literal(true), session: text, response: text, turns: Type.Integer({ minimum: 1 }), model: ModelSchema, usage: UsageSchema, usageComplete: Type.Boolean(), contextUsage: ContextUsageSchema, ...totals }, object), FailureSchema]);
31
+ export const ListSchema = Type.Union([Type.Object({ ok: Type.Literal(true), sessions: Type.Array(Type.Object({
32
+ session: text, label: text, turns: Type.Integer({ minimum: 0 }), model: ModelSchema, busy: Type.Boolean(),
33
+ ...totals,
34
+ historyBytes: Type.Integer({ minimum: 0 }), contextUsage: ContextUsageSchema
35
+ }, object)) }, object), FailureSchema]);
36
+ export const CloseSchema = Type.Union([Type.Object({ ok: Type.Literal(true), session: text }, object), FailureSchema]);