Merge remote-tracking branch 'origin/main' into add-claude-code-support
# Conflicts: # package-lock.json # src/agent/models.ts
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
|
||||
export type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
|
||||
export interface Stamp { epoch: number; authorityRevision: number; revision: number }
|
||||
interface StatusValue extends Stamp { status: ChatGPTStatus }
|
||||
interface ModelsValue extends StatusValue { accountId: string | null; models: { id: string; name: string }[] }
|
||||
interface LeaseValue extends Stamp { accessToken: string; accountId: string }
|
||||
interface Pending { op: string; resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }
|
||||
interface BridgeState {
|
||||
status: ChatGPTStatus; stamp: Stamp; models: Model<"openai-responses">[]; catalogAccount: string | null;
|
||||
pending: Map<string, Pending>; controllers: Map<AbortController, boolean>; listeners: Set<() => void>; counter: number; catalogRequest: number;
|
||||
}
|
||||
const key = Symbol.for("lh.chatgpt.bridge");
|
||||
const unavailable = (): ChatGPTStatus => ({ available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null });
|
||||
const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision].every((v) => Number.isSafeInteger(v) && v >= 0);
|
||||
/** Next dev also uses parent/child IPC. IPC alone is not our desktop
|
||||
* transport: require the same launch markers set by Electron startServer.
|
||||
* These identify the transport, not a substitute for admission/lease checks. */
|
||||
function desktopIPCConnected(): boolean {
|
||||
return process.env.LH_DESKTOP_SERVER === "1" && (process.env.LH_DESKTOP_API_SECRET?.length ?? 0) >= 64 && typeof process.send === "function" && process.connected === true;
|
||||
}
|
||||
function bridge(): BridgeState {
|
||||
const global = globalThis as typeof globalThis & { [key]?: BridgeState };
|
||||
if (!global[key]) {
|
||||
const state: BridgeState = { status: unavailable(), stamp: { epoch: 0, authorityRevision: 0, revision: 0 }, models: [], catalogAccount: null, pending: new Map(), controllers: new Map(), listeners: new Set(), counter: 0, catalogRequest: 0 };
|
||||
global[key] = state;
|
||||
process.on("message", (raw: unknown) => {
|
||||
const m = raw as { channel?: string; kind?: string; id?: string; op?: string; ok?: boolean; value?: unknown; stamp: Stamp; status: ChatGPTStatus; invalidate?: string; error?: string };
|
||||
if (!desktopIPCConnected() || m?.channel !== "lh:chatgpt") return;
|
||||
if (m.kind === "state" && validStamp(m.stamp) && m.stamp.revision >= state.stamp.revision) {
|
||||
acceptStatus(state, { ...m.stamp, status: m.status });
|
||||
if (m.invalidate === "all") { state.catalogRequest++; clearCatalog(state); }
|
||||
if (m.invalidate === "all" || m.invalidate === "background") {
|
||||
for (const [controller, background] of state.controllers) if (m.invalidate === "all" || background) controller.abort(m.invalidate === "all" ? "all" : "background");
|
||||
}
|
||||
for (const listener of state.listeners) listener();
|
||||
}
|
||||
if (m.id) {
|
||||
const call = state.pending.get(m.id);
|
||||
if (!call || call.op !== m.op) return;
|
||||
clearTimeout(call.timer); state.pending.delete(m.id);
|
||||
if (m.ok === true) call.resolve(m.value);
|
||||
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
|
||||
}
|
||||
});
|
||||
process.on("disconnect", () => {
|
||||
state.status = unavailable(); clearCatalog(state); state.catalogRequest++;
|
||||
for (const controller of state.controllers.keys()) controller.abort("all");
|
||||
for (const call of state.pending.values()) { clearTimeout(call.timer); call.reject(new Error("The desktop connection closed.")); }
|
||||
state.pending.clear();
|
||||
for (const listener of state.listeners) listener();
|
||||
});
|
||||
}
|
||||
return global[key];
|
||||
}
|
||||
function clearCatalog(state: BridgeState) { state.models = []; state.catalogAccount = null; }
|
||||
function acceptStatus(state: BridgeState, value: StatusValue) {
|
||||
if (!validStamp(value) || value.revision < state.stamp.revision) return;
|
||||
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.status.activeId !== state.status.activeId) clearCatalog(state);
|
||||
state.status = value.status; state.stamp = { epoch: value.epoch, authorityRevision: value.authorityRevision, revision: value.revision };
|
||||
}
|
||||
/** Runtime-only inherited IPC. No HTTP credential route or renderer method. */
|
||||
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
|
||||
const state = bridge();
|
||||
if (!desktopIPCConnected()) return Promise.reject(new Error("ChatGPT sign-in is available only in the desktop app with a private server connection."));
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const id = `${process.pid}:${++state.counter}`;
|
||||
const timer = setTimeout(() => { state.pending.delete(id); reject(new Error("The ChatGPT desktop connection timed out.")); }, 45000);
|
||||
state.pending.set(id, { op, resolve: (v) => resolve(v as T), reject, timer });
|
||||
process.send!({ ...fields, channel: "lh:chatgpt", id, op }, (error) => {
|
||||
if (error) { clearTimeout(timer); state.pending.delete(id); reject(new Error("The ChatGPT desktop connection closed.")); }
|
||||
});
|
||||
});
|
||||
}
|
||||
export function chatGPTStatus(): ChatGPTStatus { return bridge().status; }
|
||||
export function chatGPTConnected(): boolean { const s = chatGPTStatus(); return desktopIPCConnected() && s.available && s.accounts.some((a) => a.id === s.activeId && a.connected && a.planEnabled === true); }
|
||||
export function chatGPTBackgroundAllowed(): boolean { const s = chatGPTStatus(); return chatGPTConnected() && s.allowBackgroundUse === true; }
|
||||
export function onChatGPTChange(listener: () => void): () => void { bridge().listeners.add(listener); return () => { bridge().listeners.delete(listener); }; }
|
||||
export function chatGPTModels(): Model<"openai-responses">[] { return bridge().models; }
|
||||
export function chatGPTModel(id: string): Model<"openai-responses"> | undefined { return chatGPTModels().find((m) => m.id === id); }
|
||||
export function chatGPTScope(selectedModelId: string): AdmissionScope {
|
||||
const state = bridge();
|
||||
if (!chatGPTConnected() || !state.catalogAccount || state.catalogAccount !== state.status.activeId || !chatGPTModel(selectedModelId)) throw new Error("ChatGPT account model catalog is unavailable");
|
||||
return { accountId: state.catalogAccount, epoch: state.stamp.epoch, authorityRevision: state.stamp.authorityRevision, selectedModelId };
|
||||
}
|
||||
export function chatGPTScopeCurrent(scope: AdmissionScope): boolean {
|
||||
const s = bridge();
|
||||
return desktopIPCConnected() && chatGPTConnected() && scope.accountId === s.catalogAccount && scope.accountId === s.status.activeId && scope.epoch === s.stamp.epoch && scope.authorityRevision === s.stamp.authorityRevision && !!chatGPTModel(scope.selectedModelId);
|
||||
}
|
||||
export async function chatGPTLease(scope: AdmissionScope, background: boolean): Promise<LeaseValue> {
|
||||
if (!chatGPTScopeCurrent(scope)) throw new Error("ChatGPT catalog authority changed");
|
||||
const value = await chatGPTRequest<LeaseValue>("lease", { background, expectedAccountId: scope.accountId, expectedAuthorityRevision: scope.authorityRevision, selectedModelId: scope.selectedModelId });
|
||||
if (!validStamp(value) || !chatGPTScopeCurrent(scope) || value.epoch !== scope.epoch || value.authorityRevision !== scope.authorityRevision || value.accountId !== scope.accountId || typeof value.accessToken !== "string" || !value.accessToken || value.accessToken.startsWith("sk-")) throw new Error("ChatGPT lease is stale");
|
||||
return value;
|
||||
}
|
||||
/** Safe status confirmation can establish consent-only denial, never parse error text. */
|
||||
export async function chatGPTDenial(scope: AdmissionScope | undefined, controller: AbortController, callerAborted: boolean, background: boolean): Promise<NoDispatchKind> {
|
||||
if (callerAborted || controller.signal.reason === "all") return "all";
|
||||
if (!scope || !chatGPTScopeCurrent(scope)) return "other";
|
||||
if (controller.signal.reason === "background") return "background";
|
||||
if (!background) return "other";
|
||||
try { const value = await chatGPTRequest<StatusValue>("status"); acceptStatus(bridge(), value); }
|
||||
catch { return "other"; }
|
||||
if (controller.signal.reason === "all" || !chatGPTScopeCurrent(scope)) return "all";
|
||||
return !chatGPTBackgroundAllowed() ? "background" : "other";
|
||||
}
|
||||
/** Preserve account order; unknown capabilities/windows are never fabricated. */
|
||||
export async function refreshChatGPTModels(supported: Model<"openai-responses">[]): Promise<void> {
|
||||
const state = bridge();
|
||||
if (!desktopIPCConnected()) { clearCatalog(state); return; }
|
||||
const sequence = ++state.catalogRequest;
|
||||
try {
|
||||
const value = await chatGPTRequest<ModelsValue>("models");
|
||||
if (sequence !== state.catalogRequest || !validStamp(value)) return;
|
||||
acceptStatus(state, value);
|
||||
if (value.epoch !== state.stamp.epoch || value.authorityRevision !== state.stamp.authorityRevision || value.accountId !== state.status.activeId || !chatGPTConnected()) return;
|
||||
state.catalogAccount = value.accountId;
|
||||
state.models = value.models.flatMap((entry) => {
|
||||
const known = supported.find((m) => m.id === entry.id && m.api === "openai-responses");
|
||||
return known ? [{ ...known, provider: "chatgpt", name: entry.name, headers: undefined, samplingParams: undefined, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }] : [];
|
||||
});
|
||||
} catch { if (sequence === state.catalogRequest) clearCatalog(state); }
|
||||
}
|
||||
export function chatGPTAbortController(background = true): { controller: AbortController; release: () => void } {
|
||||
const state = bridge(); const controller = new AbortController(); state.controllers.set(controller, background);
|
||||
return { controller, release: () => { state.controllers.delete(controller); } };
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AgentEvent, AgentMessage, PrepareRequestContext } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import { nativeInputCause, needsContinuation, type LhMessage } from "../core/native-messages.ts";
|
||||
|
||||
export interface AdmissionScope { accountId: string; epoch: number; authorityRevision: number; selectedModelId: string }
|
||||
export type NoDispatchKind = "background" | "all" | "other";
|
||||
export interface ChatGPTDispatchLifecycle {
|
||||
captured(scope: AdmissionScope): void;
|
||||
beforeDispatch(scope: AdmissionScope): void;
|
||||
noDispatch(kind: NoDispatchKind): void;
|
||||
}
|
||||
interface CycleOptions {
|
||||
initialMessages: AgentMessage[];
|
||||
staged?: AgentMessage[];
|
||||
foregroundOwed: boolean;
|
||||
purpose: { background: boolean };
|
||||
scopeCurrent: (scope: AdmissionScope) => boolean;
|
||||
commitStaged?: () => void;
|
||||
restoreAutomatic: (automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>) => void;
|
||||
}
|
||||
const idOf = (m: AgentMessage) => (m as { id?: string }).id;
|
||||
/** One logical turn, not an SDK loop. No credentials, persisted provenance or wire protocol. */
|
||||
export class ChatGPTRequestCycle implements ChatGPTDispatchLifecycle {
|
||||
private release?: (event: AgentEvent) => void;
|
||||
private held: AgentEvent[] = [];
|
||||
private snapshot: AgentMessage[] = [];
|
||||
private seen: Set<string>;
|
||||
private automatic = new Set<string>();
|
||||
private committed = false;
|
||||
private denied?: NoDispatchKind;
|
||||
private scope?: AdmissionScope;
|
||||
private originalScope?: AdmissionScope;
|
||||
private foregroundOwed: boolean;
|
||||
private inheritedBackground: boolean;
|
||||
private resumed = false;
|
||||
private userAborted = false;
|
||||
private stagedCommitted = false;
|
||||
private started = false;
|
||||
private anyDispatch = false;
|
||||
private firstResolved = false;
|
||||
private resolveAdmission!: (admitted: boolean) => void;
|
||||
readonly admission = new Promise<boolean>((resolve) => { this.resolveAdmission = resolve; });
|
||||
constructor(private readonly options: CycleOptions) {
|
||||
this.seen = new Set(options.initialMessages.map(idOf).filter((id): id is string => !!id));
|
||||
for (const message of options.staged ?? []) { const id = idOf(message); if (id) this.seen.delete(id); }
|
||||
this.foregroundOwed = options.foregroundOwed;
|
||||
this.inheritedBackground = options.purpose.background;
|
||||
}
|
||||
get dispatched(): boolean { return this.anyDispatch; }
|
||||
get deniedBeforeDispatch(): boolean { return this.denied !== undefined; }
|
||||
abort(): void { this.userAborted = true; }
|
||||
bindRelease(release: (event: AgentEvent) => void): () => void { this.release = release; return () => { this.release = undefined; }; }
|
||||
prepare(request: PrepareRequestContext): void {
|
||||
const messages = request.context.messages as AgentMessage[];
|
||||
this.snapshot = [...messages]; this.automatic = new Set();
|
||||
let learner = false;
|
||||
for (const m of messages) {
|
||||
const id = idOf(m); if (!id || this.seen.has(id)) continue;
|
||||
const cause = nativeInputCause(m, messages.slice(0, messages.indexOf(m)));
|
||||
if (cause === "automatic") this.automatic.add(id);
|
||||
if (cause === "learner") learner = true;
|
||||
}
|
||||
if (this.automatic.size > 0) this.options.purpose.background = true;
|
||||
const retained = messages.filter((m) => !this.automatic.has(idOf(m) ?? ""));
|
||||
this.foregroundOwed ||= learner || (!this.inheritedBackground && needsContinuation(retained as LhMessage[]));
|
||||
}
|
||||
hold(event: AgentEvent): boolean {
|
||||
if (event.type === "agent_start") { if (this.started) return true; this.started = true; return false; }
|
||||
if (event.type === "agent_end") return true; // settle only after native activeRun is cleared
|
||||
if (event.type === "turn_start") {
|
||||
this.committed = false; this.denied = undefined; this.scope = undefined;
|
||||
// A retained learner batch from the denied cycle already owns its turn_start.
|
||||
if (!this.held.some((e) => e.type === "turn_start")) this.held.push(event);
|
||||
return true;
|
||||
}
|
||||
if (!this.committed) { this.held.push(event); return true; }
|
||||
return false;
|
||||
}
|
||||
captured(scope: AdmissionScope): void {
|
||||
if (this.originalScope && this.resumed && JSON.stringify(scope) !== JSON.stringify(this.originalScope)) throw new Error("ChatGPT recovery scope changed");
|
||||
this.scope = { ...scope };
|
||||
this.originalScope ??= { ...scope };
|
||||
}
|
||||
beforeDispatch(scope: AdmissionScope): void {
|
||||
if (this.userAborted || this.denied || this.committed || !this.scope || JSON.stringify(scope) !== JSON.stringify(this.scope) || !this.options.scopeCurrent(scope)) throw new Error("ChatGPT admission cancelled");
|
||||
if (!this.stagedCommitted && (this.options.staged?.length ?? 0) > 0) {
|
||||
this.options.commitStaged?.(); // synchronous durable prefix, before visibility and fetch
|
||||
this.stagedCommitted = true;
|
||||
const stagedEvents: AgentEvent[] = this.options.staged!.flatMap((message) => [{ type: "message_start" as const, message }, { type: "message_end" as const, message }]);
|
||||
const boundary = this.held.findIndex((event) => event.type === "turn_start");
|
||||
this.held.splice(boundary < 0 ? 0 : boundary + 1, 0, ...stagedEvents);
|
||||
}
|
||||
this.committed = true; this.anyDispatch = true; this.foregroundOwed = false;
|
||||
this.inheritedBackground = this.options.purpose.background;
|
||||
for (const m of this.snapshot) { const id = idOf(m); if (id) this.seen.add(id); }
|
||||
const events = this.held; this.held = [];
|
||||
for (const event of events) this.release?.(event);
|
||||
this.resolveFirst(true);
|
||||
}
|
||||
noDispatch(kind: NoDispatchKind): void { if (!this.committed) this.denied = this.userAborted ? "all" : kind; }
|
||||
acceptsUsage(_message: AssistantMessage): boolean { return this.committed; }
|
||||
private resolveFirst(value: boolean) { if (!this.firstResolved) { this.firstResolved = true; this.resolveAdmission(value); } }
|
||||
finishNativeCycle(): { action: "settle" | "resumeForeground"; messages: AgentMessage[] } {
|
||||
if (!this.denied) { this.resolveFirst(this.anyDispatch); return { action: "settle", messages: [] }; }
|
||||
const messages = this.snapshot.filter((m) => !this.automatic.has(idOf(m) ?? ""));
|
||||
const retainedIds = new Set(messages.map(idOf).filter((id): id is string => !!id));
|
||||
this.options.restoreAutomatic(this.automatic, retainedIds);
|
||||
this.resolveFirst(false); // queue deferral is installed before initial fire cargo returns
|
||||
this.held = this.held.filter((e) => (e.type === "turn_start") || ((e.type === "message_start" || e.type === "message_end") && e.message.role !== "assistant" && !this.automatic.has(idOf(e.message) ?? "")));
|
||||
const retry = this.denied === "background" && !this.userAborted && !this.resumed && this.foregroundOwed && !!this.scope && this.options.scopeCurrent(this.scope);
|
||||
if (retry) {
|
||||
this.resumed = true; this.options.purpose.background = false;
|
||||
this.seen = retainedIds;
|
||||
this.denied = undefined;
|
||||
// Staged automatic input must not be resurrected on foreground recovery.
|
||||
this.options.staged = undefined;
|
||||
return { action: "resumeForeground", messages };
|
||||
}
|
||||
this.held = [];
|
||||
return { action: "settle", messages };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Api, AssistantMessage, Model, SimpleStreamOptions, StreamFunction, TranscriptContext } from "@earendil-works/pi-ai";
|
||||
import { streamSimple } from "@earendil-works/pi-ai/api/openai-responses";
|
||||
import { AssistantMessageEventStream } from "@earendil-works/pi-ai/utils/event-stream";
|
||||
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
|
||||
import { chatGPTAbortController, chatGPTScope, chatGPTScopeCurrent, chatGPTLease, chatGPTDenial } from "./chatgpt-bridge.ts";
|
||||
import type { AdmissionScope, ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
|
||||
|
||||
const USAGE = "https://chatgpt.com/settings/usage";
|
||||
export function chatGPTStreamError(message: string): string {
|
||||
if (/subscription_sharing_usage_limit_exceeded|quota|429/.test(message)) return `ChatGPT plan usage limit reached. Manage usage at ${USAGE}. No API-key fallback was used.`;
|
||||
if (/scope|authorization_context|permission/.test(message)) return "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.";
|
||||
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
|
||||
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
|
||||
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
|
||||
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
|
||||
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
|
||||
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
|
||||
return "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.";
|
||||
}
|
||||
|
||||
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
|
||||
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> {
|
||||
const payload = raw as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
|
||||
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
|
||||
if (payload[key] !== undefined) out[key] = payload[key];
|
||||
}
|
||||
if (!Array.isArray(payload.input)) throw new Error("ChatGPT requires stateless input history");
|
||||
out.input = payload.input.map((item) => item?.role === "system" ? { ...item, role: "developer" } : item);
|
||||
if (Array.isArray(payload.tools) && payload.tools.length) {
|
||||
if (payload.tools.some((tool) => tool.type !== "function" && tool.type !== "custom")) throw new Error("Unsupported ChatGPT tool capability");
|
||||
out.tools = [{ type: "namespace", name: "learnharness", description: "LearnHarness local learning tools", tools: payload.tools }];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Only ChatGPT history is translated. Mixed API-key history keeps its actual identity. */
|
||||
export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<Api>): TranscriptContext {
|
||||
// Apply Pi's normal foreign-model signature treatment BEFORE the alias
|
||||
// becomes openai, or real API-key turns could look like subscription turns.
|
||||
const messages = transformMessages(context.messages, logicalModel);
|
||||
return { ...context, messages: messages.map((m) => m.role === "assistant" && m.provider === "chatgpt" ? { ...m, provider: "openai" } : m) };
|
||||
}
|
||||
function empty(model: Model<Api>): AssistantMessage {
|
||||
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
|
||||
}
|
||||
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> {
|
||||
return (logicalModel, context, options) => {
|
||||
const output = new AssistantMessageEventStream();
|
||||
const purpose = background();
|
||||
const { controller, release } = chatGPTAbortController(purpose);
|
||||
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
|
||||
void (async () => {
|
||||
let partial = empty(logicalModel);
|
||||
let completed = false;
|
||||
let failure = "";
|
||||
let scope: AdmissionScope | undefined;
|
||||
let committed = false;
|
||||
let terminal = false;
|
||||
const check = () => { if (signal.aborted || !scope || !chatGPTScopeCurrent(scope)) throw new Error("cancelled or catalog authority changed"); };
|
||||
try {
|
||||
if (logicalModel.provider !== "chatgpt") throw new Error("ChatGPT account catalog has no selected model");
|
||||
if (Object.keys(options?.headers ?? {}).some((key) => key.toLowerCase() === "authorization")) throw new Error("Authorization override is forbidden");
|
||||
scope = chatGPTScope(logicalModel.id);
|
||||
lifecycle?.captured(scope);
|
||||
const credentials = await chatGPTLease(scope, purpose);
|
||||
check();
|
||||
// NEVER dispatch through Models under openai: that would expose ambient API-key fallback.
|
||||
const transport: Model<"openai-responses"> = { ...logicalModel, api: "openai-responses", provider: "openai", baseUrl: "https://api.openai.com/v1", headers: undefined, samplingParams: undefined, compat: { ...logicalModel.compat, supportsDeveloperRole: true } };
|
||||
const delegate = options?.fetch ?? globalThis.fetch;
|
||||
const source = streamSimple(transport, chatGPTHistory(context, logicalModel), {
|
||||
...options,
|
||||
apiKey: credentials.accessToken,
|
||||
signal,
|
||||
maxRetries: 0,
|
||||
headers: { "OpenAI-Beta": null, ...options?.headers },
|
||||
fetch: (input, init) => {
|
||||
check();
|
||||
if (committed) throw new Error("ChatGPT does not retry admitted requests");
|
||||
lifecycle?.beforeDispatch(scope!);
|
||||
committed = true;
|
||||
return delegate(input, init);
|
||||
},
|
||||
onPayload: async (raw, model) => {
|
||||
const replacement = await options?.onPayload?.(raw, model);
|
||||
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id);
|
||||
check();
|
||||
return payload;
|
||||
},
|
||||
onProviderStreamEvent: async (raw, model) => {
|
||||
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } };
|
||||
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
|
||||
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
|
||||
await options?.onProviderStreamEvent?.(raw, model);
|
||||
},
|
||||
});
|
||||
for await (const event of source) {
|
||||
const message = "partial" in event ? event.partial : event.type === "done" ? event.message : event.error;
|
||||
// Copy the message, not its tool/reasoning blocks: IDs/signatures remain byte-for-byte intact.
|
||||
partial = { ...message, provider: "chatgpt" };
|
||||
if (event.type === "done") {
|
||||
if (!completed || failure) throw new Error(failure || "missing terminal response.completed");
|
||||
terminal = true;
|
||||
output.push({ ...event, message: partial });
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(failure || event.error.errorMessage || "ChatGPT stream error");
|
||||
} else output.push({ ...event, partial });
|
||||
}
|
||||
} catch (err) {
|
||||
if (terminal) return;
|
||||
if (!committed) lifecycle?.noDispatch(await chatGPTDenial(scope, controller, options?.signal?.aborted === true, purpose));
|
||||
partial = { ...partial, stopReason: signal.aborted ? "aborted" : "error", errorMessage: chatGPTStreamError(err instanceof Error ? err.message : "") };
|
||||
output.push({ type: "error", reason: partial.stopReason as "error" | "aborted", error: partial });
|
||||
} finally { release(); output.end(); }
|
||||
})();
|
||||
return output;
|
||||
};
|
||||
}
|
||||
+9
-7
@@ -12,6 +12,9 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { type ModelRole } from "../../config.ts";
|
||||
import { DEFAULT_THINKING_ORDER } from "../core/settings.ts";
|
||||
import packageMetadata from "../../package.json" with { type: "json" };
|
||||
import { chatGPTModel } from "./chatgpt-bridge.ts";
|
||||
import { chatGPTStream } from "./chatgpt-stream.ts";
|
||||
import type { ChatGPTDispatchLifecycle } from "./chatgpt-request-cycle.ts";
|
||||
import { createClaudeCodeProvider } from "./claude-code/provider.ts";
|
||||
import { streamClaudeCode } from "./claude-code/stream.ts";
|
||||
|
||||
@@ -96,9 +99,9 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
|
||||
* key is resolved by pi-ai itself from the environment per request (the
|
||||
* keys seam reflects file-saved keys into env), so the engine never
|
||||
* hardcodes OpenRouter's. */
|
||||
export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
||||
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
|
||||
const models: Models = piModels();
|
||||
const model = models.getModel(spec.provider, spec.id);
|
||||
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : models.getModel(spec.provider, spec.id);
|
||||
if (!model) {
|
||||
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
||||
}
|
||||
@@ -114,11 +117,10 @@ export function piEngineForSpec(spec: ResolvedModelSpec): PiEngine {
|
||||
// bound streamSimple: the agent loop forwards state.thinkingLevel as
|
||||
// `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps
|
||||
// the pin honest even when config or settings drift off the map.
|
||||
streamFn: (requestModel, context, options) =>
|
||||
models.streamSimple(requestModel, context, {
|
||||
...options,
|
||||
reasoning: level === "off" ? undefined : level,
|
||||
}),
|
||||
streamFn: (requestModel, context, options) => {
|
||||
const stream = spec.provider === "chatgpt" ? chatGPTStream(typeof background === "function" ? background : () => background, lifecycle) : models.streamSimple.bind(models);
|
||||
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -777,6 +777,7 @@ export function callUsage(usage: Usage): CallUsage {
|
||||
outputTokens: usage.output,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheWriteTokens: usage.cacheWrite,
|
||||
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||
// Optional in pi's Usage (providers that never emit reasoning
|
||||
// tokens leave it unset) — persisted as 0, never dropped.
|
||||
reasoningTokens: usage.reasoning ?? 0,
|
||||
|
||||
@@ -137,6 +137,9 @@ export function browserServiceSpawnEnv(
|
||||
const out: Record<string, string | undefined> = { ...env };
|
||||
delete out.ELECTRON_RUN_AS_NODE;
|
||||
delete out.LH_DEV_URL;
|
||||
// The artifact renderer is not an authorized app API client.
|
||||
delete out.LH_DESKTOP_API_SECRET;
|
||||
delete out.LH_DESKTOP_SERVER;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,8 @@ Tool outputs may be error strings (e.g. search unconfigured, a page that failed
|
||||
Adapt: try another URL or another search angle; if research is genuinely impossible, say so
|
||||
in the brief instead of inventing facts.
|
||||
|
||||
If web_search reports that search is unavailable because credentials are missing, do not retry it with different queries. Use web_fetch when you have source URLs, and continue with any other available sources. If the missing search capability materially limits your work, state the limitation and the missing-key setup needed in your brief. Never invent search results or sources.
|
||||
|
||||
Your FINAL assistant message is your entire deliverable — it must stand alone, using this format:
|
||||
|
||||
## Summary
|
||||
@@ -70,8 +72,7 @@ export interface ResearcherRunOptions {
|
||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||
* provider per call, null = honestly unavailable (the web_search tool
|
||||
* reports that in its output — no crash, no silent fallback). Absent
|
||||
* seam reads as unavailable: the server wiring always injects one; the
|
||||
* dispatch-level drop (jobTools) keeps doomed jobs from even starting. */
|
||||
* seam reads as unavailable; web_fetch remains usable without it. */
|
||||
search?: () => SearchProvider | null;
|
||||
/** The researcher's PDF dir (PDF wave, 2026-10-07): when present, the
|
||||
* toolset gains web_fetch_pdf + pdf_info/pdf_extract/pdf_search scoped
|
||||
|
||||
@@ -82,9 +82,8 @@ export interface JobRunnerDeps {
|
||||
/** The search seam (search port wave, 2026-09-20): resolves the LIVE
|
||||
* provider fresh per job — server/search.ts composition injected by
|
||||
* the wiring; null = honestly unavailable (the researcher's web_search
|
||||
* reports it). Absent seam (tests) reads the same. The dispatch-level
|
||||
* drop (jobTools' searchAvailable) uses the same signal — jobs already
|
||||
* in flight when a key disappears still fail honestly, never crash. */
|
||||
* reports it as recoverable text). Absent seam (tests) reads the same;
|
||||
* research dispatch and web_fetch do not require search credentials. */
|
||||
searchProvider?: () => SearchProvider | null;
|
||||
/** Key-presence gate (provider-key interlude, 2026-09-25): given the
|
||||
* resolved engine's provider + the kind's slot tier, return the
|
||||
|
||||
@@ -18,7 +18,8 @@ import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
|
||||
import { healToolRecord } from "./tools/heal.ts";
|
||||
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
|
||||
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
|
||||
import { toLlmMessages } from "../core/native-messages.ts";
|
||||
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
|
||||
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
|
||||
|
||||
/**
|
||||
* The two teaching-side agents (docs/probe-teach-split.md): the PROBER and
|
||||
@@ -97,6 +98,8 @@ export type StepUsageCollector = (usage: Usage) => void;
|
||||
export interface AgentBuildOptions {
|
||||
/** Model + stream function — tests inject a scripted faux engine. */
|
||||
engine?: PiEngine;
|
||||
callPurpose?: { background: boolean };
|
||||
requestCycle?: ChatGPTRequestCycle;
|
||||
/** The initial transcript, as pi messages (the record IS agent state). */
|
||||
messages?: AgentMessage[];
|
||||
/** Money channel: one call per model request. */
|
||||
@@ -178,6 +181,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
|
||||
// the normal no-observer path (tests, env-off).
|
||||
const providerEvents = opts.chatId ? providerEventObserver(opts.chatId, kind) : undefined;
|
||||
const seenInputs = new Set((opts.messages ?? []).map((m) => (m as { id?: string }).id).filter(Boolean));
|
||||
const agent = new Agent({
|
||||
identity: APP_IDENTITY,
|
||||
sessionId: opts.chatId,
|
||||
@@ -211,13 +215,31 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
|
||||
// an abort's dangling question call gets its INTERRUPTED result. It
|
||||
// must never throw (pi's convertToLlm contract).
|
||||
convertToLlm: toLlmMessages,
|
||||
prepareRequest: (request) => {
|
||||
if (opts.callPurpose) {
|
||||
let learner = false, automatic = false;
|
||||
const messages = request.context.messages;
|
||||
for (const message of messages) {
|
||||
const id = (message as { id?: string }).id;
|
||||
const cause = nativeInputCause(message, messages.slice(0, messages.indexOf(message)));
|
||||
if (cause === "neutral") continue;
|
||||
if (id && seenInputs.has(id)) continue;
|
||||
if (id) seenInputs.add(id);
|
||||
if (cause === "learner" && id) learner = true;
|
||||
else automatic = true; // missing/unrecognized identity never grants foreground
|
||||
}
|
||||
if (automatic) opts.callPurpose.background = true;
|
||||
else if (learner) opts.callPurpose.background = false;
|
||||
}
|
||||
opts.requestCycle?.prepare(request);
|
||||
},
|
||||
});
|
||||
if (opts.onUsage) {
|
||||
agent.subscribe((event: AgentEvent) => {
|
||||
if (event.type === "message_end" && event.message.role === "assistant" && "usage" in event.message) {
|
||||
// A cancelled call is not a billed step (partial/estimated
|
||||
// usage on an aborted message is not money spent).
|
||||
if (event.message.stopReason === "aborted") return;
|
||||
if (event.message.stopReason === "aborted" || (opts.requestCycle && !opts.requestCycle.acceptsUsage(event.message))) return;
|
||||
opts.onUsage!(event.message.usage);
|
||||
}
|
||||
});
|
||||
|
||||
+5
-13
@@ -268,8 +268,7 @@ export interface ProberToolContext extends ProbeDoneContext {
|
||||
jobStore: JobStore;
|
||||
/** Fire the background runner for a freshly created job. */
|
||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||
/** Search availability — the research dispatch tool's honest-disable
|
||||
* signal (same gate as the teacher's). Absent = available. */
|
||||
/** Retained search-status seam; research dispatch is available keylessly. */
|
||||
searchAvailable?: () => boolean;
|
||||
/** The prober's graph-map reader (wave 7): present = the two map tools
|
||||
* (list_sessions / list_propositions) are registered. The composition
|
||||
@@ -331,13 +330,12 @@ export function createProberTools(
|
||||
// research + pdf are ALWAYS available in every phase — the
|
||||
// prober can verify facts or read learner PDFs while assessing.
|
||||
// demo/visual stay teach-phase-only (they need the lesson's plan
|
||||
// context). Same gating as the teacher: toggle-filtered here,
|
||||
// research subject to the searchAvailable gate (honestly absent
|
||||
// when no engine is live).
|
||||
// context). Same user-toggle filtering as the teacher; research
|
||||
// remains available without a search-provider key.
|
||||
...(jobs.research ? { research: jobs.research } : {}),
|
||||
pdf: jobs.pdf!,
|
||||
// The map reader (wave 7): present only when the settings gate
|
||||
// opened — same registration-time drop as the search gate.
|
||||
// opened.
|
||||
...(ctx.graphStore
|
||||
? proberGraphTools({ chatStore: ctx.chatStore, graphStore: ctx.graphStore })
|
||||
: {}),
|
||||
@@ -400,9 +398,7 @@ export interface TeacherToolContext extends PlanToolContext {
|
||||
* rides along — the spawn fires mid-stream, before the tool part is
|
||||
* persisted to the transcript (a transcript scan would race it). */
|
||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||
/** Search availability (search port wave, 2026-09-20): the research
|
||||
* dispatch tool's honest-disable signal — no live engine = the tool
|
||||
* drops from this registry (JobToolContext.searchAvailable). */
|
||||
/** Retained search-status seam; research dispatch is available keylessly. */
|
||||
searchAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
@@ -462,10 +458,6 @@ export function createTeacherTools(
|
||||
find: findTool,
|
||||
grep: grepTool,
|
||||
...planTools(ctx),
|
||||
// Fix wave (2026-09-20): searchAvailable was RECEIVED here but
|
||||
// DROPPED when building the inner JobToolContext — the drop-gate
|
||||
// always took its ?? true default. Threaded now: the live signal
|
||||
// reaches jobTools' research gate.
|
||||
...jobTools({ chatId: ctx.chatId, jobStore: ctx.jobStore, spawn: ctx.spawn, searchAvailable: ctx.searchAvailable }),
|
||||
},
|
||||
withoutMechanics(enabled),
|
||||
|
||||
+14
-21
@@ -23,11 +23,9 @@ export interface JobToolContext {
|
||||
* input rides along because the spawn happens MID-STREAM, before
|
||||
* onFinish persists the tool part — a transcript scan would race it. */
|
||||
spawn: (job: JobRecord, input: ResearchInput | DemoInput | VisualInput | PdfInput) => void;
|
||||
/** Search availability (search port wave, 2026-09-20): the honest
|
||||
* disable signal for the research dispatch tool — no live engine
|
||||
* means NO research tool in the registry (owner decision: registry-
|
||||
* level drop, no doomed dispatch, no silent fallback). Absent =
|
||||
* available (tests + any seam without search wiring). */
|
||||
/** Search-provider availability, retained for existing composition seams.
|
||||
* Research dispatch does not depend on it: web_fetch is keyless and
|
||||
* web_search reports a missing provider as a recoverable tool result. */
|
||||
searchAvailable?: () => boolean;
|
||||
}
|
||||
|
||||
@@ -127,25 +125,20 @@ const PDF_DESCRIPTION =
|
||||
* context (chatId + jobStore + the spawn seam), so concurrent chats never
|
||||
* share state. chat-handler wires it each turn; tests wire their own.
|
||||
*
|
||||
* The research tool drops from the registry when ctx.searchAvailable reports
|
||||
* no live engine (2026-09-20) — the teacher never sees it, so a keyless
|
||||
* server can't dispatch doomed research jobs. Jobs ALREADY in flight when
|
||||
* a key disappears still run to their honest failure (runner backstop).
|
||||
* Research remains available without a search-provider key; the role's
|
||||
* existing user-toggle filtering still applies outside this registry.
|
||||
*/
|
||||
export function jobTools(ctx: JobToolContext): Record<string, AgentTool<any>> {
|
||||
const searchOn = ctx.searchAvailable?.() ?? true;
|
||||
return {
|
||||
...(searchOn && {
|
||||
research: {
|
||||
name: "research",
|
||||
label: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
parameters: researchInputSchema,
|
||||
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
||||
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
),
|
||||
},
|
||||
}),
|
||||
research: {
|
||||
name: "research",
|
||||
label: "research",
|
||||
description: RESEARCH_DESCRIPTION,
|
||||
parameters: researchInputSchema,
|
||||
execute: serverExecute("research", researchInputSchema, (input, { toolCallId }) =>
|
||||
dispatch(ctx, "research", toolCallId, RESEARCH_ACK, input),
|
||||
),
|
||||
},
|
||||
demo: {
|
||||
name: "demo",
|
||||
label: "demo",
|
||||
|
||||
@@ -40,8 +40,8 @@ export interface PracticeToolContext {
|
||||
graphNodes?: () => ReadonlyArray<Pick<import("../../core/contracts/graph.ts").GraphNode, "id" | "name">>;
|
||||
/** THE RARELY-NEEDED SET (owner round 10, 2026-10-05): the map readers
|
||||
* behind the SAME settings gate as the prober's (graphToolsEnabled at
|
||||
* the composition root); the research + pdf jobs with the honest
|
||||
* searchAvailable drop. Each absent (tests) = absent from the registry. */
|
||||
* the composition root); the research + pdf jobs (both keyless).
|
||||
* Each absent context (tests) = absent from the registry. */
|
||||
graph?: GraphToolContext;
|
||||
jobs?: JobToolContext;
|
||||
}
|
||||
@@ -225,8 +225,8 @@ export function practiceTools(ctx: PracticeToolContext): Record<string, AgentToo
|
||||
const gate = createPracticeGate(ctx);
|
||||
// THE RARELY-NEEDED SET (owner round 10): read/ls/find/grep over the
|
||||
// learner's files (always — the same read-only set the teacher and
|
||||
// prober carry), the research + pdf jobs (research honestly absent
|
||||
// when no search engine is live; demo/visual stay teach-only), and
|
||||
// prober carry), the research + pdf jobs (available without search
|
||||
// keys; demo/visual stay teach-only), and
|
||||
// the prober's map readers when the settings gate opened. None are
|
||||
// toggle-filtered: problem/conclude are the session; these are
|
||||
// exceptions the doctrine says to reach for rarely.
|
||||
|
||||
+62
-51
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* COMPACTION (owner round 14, 2026-10-05): the context-fitting mechanic,
|
||||
* mirroring pi's design (docs aside, this is OUR implementation — pi's
|
||||
* compaction lives in the coding-agent package we don't depend on).
|
||||
* with a bounded local port of Pi coding-agent 1.4.0 cutoff selection.
|
||||
* No coding-agent dependency. See docs/compaction-accounting.md for the
|
||||
* behavior boundary, native golden fixtures, and upstream MIT notice.
|
||||
*
|
||||
* THE CONTRACT: full history stays persisted in the record — extraction
|
||||
* and the user's transcript keep reading everything. A compaction marker
|
||||
@@ -38,7 +39,7 @@ import { COMPACTED_HISTORY_HEADER } from "./contracts/chat.ts";
|
||||
import type { LhMessage, LhMarkerMessage } from "./native-messages.ts";
|
||||
import { newMessageId } from "./native-messages.ts";
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { AssistantMessage, ToolResultMessage, SystemMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
// ─── constants (the tunables, owner-approved) ────────────────────────────────
|
||||
|
||||
@@ -77,9 +78,8 @@ export function calculateContextTokens(usage: {
|
||||
);
|
||||
}
|
||||
|
||||
/** Estimate a message's context tokens from its WIRE-relevant text only
|
||||
* (pi's estimator): text + thinking + toolCall name/args for assistants,
|
||||
* content text for user/toolResult/custom. NEVER JSON.stringify of the
|
||||
/** Pi 1.4.0's heuristic: text + thinking + toolCall name/args for
|
||||
* assistants, content text plus a fixed image allowance for other roles. NEVER JSON.stringify of the
|
||||
* whole message — record scaffolding (ids, usage, responseId, app-side
|
||||
* details, escaped quotes/backslashes) inflates ~3x (measured on the
|
||||
* 2026-10-05 relational-databases incident: 84k estimated vs ~30k real). */
|
||||
@@ -100,23 +100,32 @@ export function estimateMessageTokens(message: LhMessage | AgentMessage): number
|
||||
}
|
||||
} else {
|
||||
chars += textCharsOfContent(content);
|
||||
if (message.role === "system") {
|
||||
const system = message as SystemMessage;
|
||||
for (const section of Object.values(system.sections ?? {})) chars += section?.length ?? 0;
|
||||
if (system.toolsAdded) chars += JSON.stringify(system.toolsAdded).length;
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
// Pi coding-agent 1.4.0 estimateTokens: fixed image allowance, NOT a
|
||||
// measurement of encrypted reasoning or actual provider vision tokens.
|
||||
const ESTIMATED_IMAGE_CHARS = 4_800;
|
||||
function textCharsOfContent(content: unknown): number {
|
||||
if (typeof content === "string") return content.length;
|
||||
if (!Array.isArray(content)) return 0;
|
||||
let chars = 0;
|
||||
for (const block of content as Array<string | { type?: string; text?: string }>) {
|
||||
if (typeof block === "string") chars += block.length;
|
||||
else if (block?.type === "text" || "text" in block) chars += block.text?.length ?? 0;
|
||||
else if (block?.type === "image") chars += ESTIMATED_IMAGE_CHARS;
|
||||
else if (block?.type === "text") chars += block.text?.length ?? 0;
|
||||
}
|
||||
return chars;
|
||||
}
|
||||
|
||||
/** The newest assistant message carrying VALID provider-reported usage
|
||||
* (pi's rule: skip aborted messages and all-zero usage — they carry no
|
||||
* (pi's rule: skip aborted/error messages and all-zero usage — they carry no
|
||||
* truth about the wire). The scan STOPS at the newest compaction
|
||||
* marker: usage captured before a compaction described the pre-cut
|
||||
* context — the next request carries the summary + kept tail instead,
|
||||
@@ -131,7 +140,7 @@ export function lastAssistantUsageIndex(
|
||||
if ((m as { role?: string }).role === "custom" && (m as LhMarkerMessage).customType === "compaction") return null;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const a = m as AssistantMessage & { stopReason?: string };
|
||||
if (a.stopReason === "aborted") continue;
|
||||
if (a.stopReason === "aborted" || a.stopReason === "error") continue;
|
||||
if (calculateContextTokens((a.usage ?? {}) as Parameters<typeof calculateContextTokens>[0]) === 0) continue;
|
||||
return i;
|
||||
}
|
||||
@@ -180,51 +189,53 @@ export function estimateTokens(messages: readonly (LhMessage | AgentMessage)[]):
|
||||
|
||||
// ─── the cut walk ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find the compaction cut index: walk BACKWARDS from the end accumulating
|
||||
* estimates until ~keepRecentTokens are kept; the cut lands there.
|
||||
*
|
||||
* HARD INVARIANT — a tool result is never separated from its call: if the
|
||||
* walk stops on a toolResult, the KEPT region extends to the assistant
|
||||
* message carrying its call (keeping the result means keeping the call).
|
||||
* This is the provider protocol's shape law: dropping a call while
|
||||
* keeping its result strands an orphan the provider rejects. A PENDING
|
||||
* question/problem call (no result yet — the pose→answer window) sits at
|
||||
* the transcript end and is always kept: the walk never crosses it.
|
||||
*
|
||||
* Returns 0 when everything must be summarized (the whole history is the
|
||||
* span — valid), or messages.length when there is nothing meaningful to
|
||||
* cut (kept budget covers everything — caller checks before compacting).
|
||||
*/
|
||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||
let kept = 0;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (kept >= keepRecentTokens) {
|
||||
// The cut candidate: extend past orphaned tool results to the
|
||||
// assistant message carrying their calls.
|
||||
let cut = i + 1;
|
||||
while (cut < messages.length && (messages[cut] as { role?: string }).role === "toolResult") {
|
||||
const callIndex = assistantIndexOfCall(messages, (messages[cut] as ToolResultMessage).toolCallId, cut);
|
||||
if (callIndex === -1) break; // no call found (defensive): stop extending
|
||||
cut = callIndex;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
kept += estimateMessageTokens(messages[i]!);
|
||||
}
|
||||
return 0; // the whole history fits inside the kept budget — summarize all
|
||||
/** Model-visible entry adapter for the local Pi 1.4.0 cutoff port.
|
||||
* Compaction entries are boundaries, not ordinary cut candidates; UI-only
|
||||
* markers have no context, just like native metadata entries. */
|
||||
function cutMessage(message: LhMessage): LhMessage | null {
|
||||
if (message.role !== "custom") return message;
|
||||
if (["compaction", "plan-block", "problem-dismissed"].includes(message.customType)) return null;
|
||||
return message;
|
||||
}
|
||||
|
||||
/** The newest assistant message at or before `before` whose content
|
||||
* carries the tool call `toolCallId` — the call a result answers. */
|
||||
function assistantIndexOfCall(messages: readonly LhMessage[], toolCallId: string, before: number): number {
|
||||
for (let i = before - 1; i >= 0; i--) {
|
||||
const m = messages[i]!;
|
||||
if ((m as { role?: string }).role !== "assistant") continue;
|
||||
const assistant = m as AssistantMessage;
|
||||
if (assistant.content.some((b) => b.type === "toolCall" && b.id === toolCallId)) return i;
|
||||
/** Local port of pi-coding-agent 1.4.0 findCutPoint (MIT).
|
||||
* Walk backwards to >= budget, then choose the nearest valid cut AT OR
|
||||
* AFTER that entry. If oversized trailing results exhaust the budget,
|
||||
* retain their preceding assistant call, not the entire session.
|
||||
* No encrypted-signature sizing and no exact retained-token guarantee.
|
||||
* Returns the first kept record index; a zero/no-progress cut drops nothing.
|
||||
* Keep this and estimateMessageTokens aligned deliberately when upgrading Pi.
|
||||
*/
|
||||
export function compactionCutIndex(messages: readonly LhMessage[], keepRecentTokens = KEEP_RECENT_TOKENS): number {
|
||||
const previous = activeCompactionOf(messages);
|
||||
const previousKept = previous ? keptIndexFor(messages, previous) : -1;
|
||||
const start = Math.max(0, previousKept);
|
||||
const points: number[] = [];
|
||||
for (let i = start; i < messages.length; i++) {
|
||||
const message = cutMessage(messages[i]!);
|
||||
if (message && (message.role === "user" || message.role === "assistant" || message.role === "custom")) points.push(i);
|
||||
}
|
||||
return -1;
|
||||
if (!points.length) return start;
|
||||
let cut = points[0]!;
|
||||
let accumulated = 0;
|
||||
for (let i = messages.length - 1; i >= start; i--) {
|
||||
const message = cutMessage(messages[i]!);
|
||||
const tokens = message ? estimateMessageTokens(message) : 0;
|
||||
if (tokens === 0) continue;
|
||||
accumulated += tokens;
|
||||
if (accumulated >= keepRecentTokens) {
|
||||
cut = points.find((candidate) => candidate >= i) ?? points.at(-1)!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Native entries preserve adjacent non-context metadata, but never walk
|
||||
// backwards across a previous compaction entry.
|
||||
while (cut > start) {
|
||||
const before = messages[cut - 1]!;
|
||||
if ((before.role === "custom" && before.customType === "compaction") || cutMessage(before)) break;
|
||||
cut--;
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
// ─── the marker ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
||||
import type { LhMessage } from "./native-messages.ts";
|
||||
|
||||
/** Latest completed conversation measurement, never a request-size estimate.
|
||||
* Ordered compaction markers invalidate every earlier reading. Live callers
|
||||
* must exclude assistant slots that have not received message_end.
|
||||
* Reasoning is already inside output; totalTokens is deliberately not used.
|
||||
*/
|
||||
export function measuredContextTokens(
|
||||
messages: readonly LhMessage[],
|
||||
incompleteIds: ReadonlySet<string> = new Set(),
|
||||
): number | null {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const message = messages[i]!;
|
||||
if (message.role === "custom" && message.customType === "compaction") return null;
|
||||
if (message.role !== "assistant" || incompleteIds.has(message.id)) continue;
|
||||
const assistant = message as AssistantMessage;
|
||||
if (!["stop", "length", "toolUse"].includes(assistant.stopReason)) continue;
|
||||
const usage = assistant.usage;
|
||||
if (!usage) continue;
|
||||
const buckets = [usage.input, usage.output, usage.cacheRead, usage.cacheWrite];
|
||||
if (buckets.some((value) => !Number.isFinite(value) || value < 0)) continue;
|
||||
const tokens = buckets.reduce((sum, value) => sum + value, 0);
|
||||
if (tokens > 0) return tokens;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -87,8 +87,8 @@ export interface ChatSettings {
|
||||
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
||||
* folds happen at read time (core/prices.ts). */
|
||||
export interface ExtractionStageUsage {
|
||||
/** The model that ran this stage, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
/** The model that ran this stage, provider-qualified ("provider:id")
|
||||
* for key-independent Pi catalog lookup at read time. */
|
||||
model: string;
|
||||
steps: CallUsage[];
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export interface ExtractionRunUsage {
|
||||
* flattened to the fields we persist (undefined = provider didn't report;
|
||||
* renderers show absent, never zero). */
|
||||
export interface CallUsage {
|
||||
/** Total input (prompt) tokens, cached portion included. */
|
||||
/** Pi-normalized fresh input tokens; cache buckets are disjoint. */
|
||||
inputTokens: number | undefined;
|
||||
outputTokens: number | undefined;
|
||||
/** Cached input tokens READ (billed at the cache-read rate, if any). */
|
||||
@@ -126,6 +126,9 @@ export interface CallUsage {
|
||||
/** Input tokens written to the cache this call (OpenRouter bills writes
|
||||
* for some providers; undefined = not reported/not billed). */
|
||||
cacheWriteTokens: number | undefined;
|
||||
/** Subset of cacheWriteTokens written with one-hour retention. Missing
|
||||
* on older logs; new native captures explicitly record zero if absent. */
|
||||
cacheWrite1hTokens?: number;
|
||||
/** The reasoning portion of the output tokens. */
|
||||
reasoningTokens: number | undefined;
|
||||
}
|
||||
@@ -144,8 +147,8 @@ export interface TurnUsage {
|
||||
* entry to the transcript (the block this turn "belongs" to). */
|
||||
messageId: string;
|
||||
at: string;
|
||||
/** The model that ran the turn, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
/** The model that ran the turn, provider-qualified ("provider:id")
|
||||
* for key-independent Pi catalog lookup at read time. */
|
||||
model: string;
|
||||
/** One entry per agent step (model call) in this turn. */
|
||||
steps: CallUsage[];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Client-safe status only. Credentials and OAuth transaction data never enter this contract. */
|
||||
export interface ChatGPTStatus {
|
||||
available: boolean;
|
||||
pending: boolean;
|
||||
/** App-wide ChatGPT background permission, independent of account selection. */
|
||||
allowBackgroundUse: boolean;
|
||||
activeId: string | null;
|
||||
accounts: { id: string; email: string; registration: string; connected: boolean; planEnabled?: boolean }[];
|
||||
error: string | null;
|
||||
catalogError?: string | null;
|
||||
}
|
||||
export interface ChatGPTControlBridge {
|
||||
status(): Promise<ChatGPTStatus>;
|
||||
login(id?: string): Promise<ChatGPTStatus>;
|
||||
cancel(): Promise<ChatGPTStatus>;
|
||||
select(id: string): Promise<ChatGPTStatus>;
|
||||
/** Explicitly forget every local registration/credential for this verified identity. */
|
||||
disconnect(id: string): Promise<ChatGPTStatus>;
|
||||
logout(id: string): Promise<ChatGPTStatus>;
|
||||
consent(enabled: boolean): Promise<ChatGPTStatus>;
|
||||
onChange(callback: () => void): () => void;
|
||||
}
|
||||
+21
-10
@@ -43,7 +43,7 @@ export interface JobRecord {
|
||||
* money-channel doctrine; cost renders "—", never $0). */
|
||||
export interface JobUsage {
|
||||
/** The model that ran the sub-agent, provider-qualified ("provider:id" —
|
||||
* model slots wave, 2026-09-20; matches PRICES' uniform keys). */
|
||||
* model slots wave, 2026-09-20; matches Pi catalog lookup keys). */
|
||||
model: string;
|
||||
/** Running totals summed over the run's model calls so far. */
|
||||
inputTokens: number | undefined;
|
||||
@@ -51,6 +51,10 @@ export interface JobUsage {
|
||||
cacheReadTokens: number | undefined;
|
||||
cacheWriteTokens: number | undefined;
|
||||
reasoningTokens: number | undefined;
|
||||
cacheWrite1hTokens?: number;
|
||||
/** Individual calls for current-catalog per-call tier pricing. Absent on
|
||||
* older jobs; never backfill them from cumulative totals. */
|
||||
calls?: CallUsage[];
|
||||
/** How many model calls the run has made (each bills separately). */
|
||||
steps: number;
|
||||
/** The MOST RECENT model call's accounting (2026-09 dock contract): the
|
||||
@@ -67,7 +71,7 @@ export interface JobUsage {
|
||||
* object with these fields folds, including the real thing and test fakes). */
|
||||
export interface UsageStepLike {
|
||||
inputTokens?: number | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } | undefined;
|
||||
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number; cacheWrite1hTokens?: number } | undefined;
|
||||
outputTokens?: number | undefined;
|
||||
outputTokenDetails?: { reasoningTokens?: number } | undefined;
|
||||
}
|
||||
@@ -80,12 +84,13 @@ export function piUsageToStep(usage: {
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cacheWrite1h?: number;
|
||||
reasoning?: number;
|
||||
}): UsageStepLike {
|
||||
return {
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite },
|
||||
inputTokenDetails: { cacheReadTokens: usage.cacheRead, cacheWriteTokens: usage.cacheWrite, cacheWrite1hTokens: usage.cacheWrite1h ?? 0 },
|
||||
outputTokenDetails: usage.reasoning !== undefined ? { reasoningTokens: usage.reasoning } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -101,6 +106,14 @@ export function accumulateJobUsage(
|
||||
): JobUsage {
|
||||
const add = (a: number | undefined, b: number | undefined): number | undefined =>
|
||||
a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0);
|
||||
const call: CallUsage = {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
cacheWrite1hTokens: step.inputTokenDetails?.cacheWrite1hTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
};
|
||||
return {
|
||||
model,
|
||||
inputTokens: add(total?.inputTokens, step.inputTokens),
|
||||
@@ -108,14 +121,12 @@ export function accumulateJobUsage(
|
||||
cacheReadTokens: add(total?.cacheReadTokens, step.inputTokenDetails?.cacheReadTokens),
|
||||
cacheWriteTokens: add(total?.cacheWriteTokens, step.inputTokenDetails?.cacheWriteTokens),
|
||||
reasoningTokens: add(total?.reasoningTokens, step.outputTokenDetails?.reasoningTokens),
|
||||
cacheWrite1hTokens: ((total?.cacheWriteTokens ?? 0) > 0 && total?.cacheWrite1hTokens === undefined) ||
|
||||
((call.cacheWriteTokens ?? 0) > 0 && call.cacheWrite1hTokens === undefined)
|
||||
? undefined : add(total?.cacheWrite1hTokens, call.cacheWrite1hTokens),
|
||||
steps: (total?.steps ?? 0) + 1,
|
||||
lastStep: {
|
||||
inputTokens: step.inputTokens,
|
||||
outputTokens: step.outputTokens,
|
||||
cacheReadTokens: step.inputTokenDetails?.cacheReadTokens,
|
||||
cacheWriteTokens: step.inputTokenDetails?.cacheWriteTokens,
|
||||
reasoningTokens: step.outputTokenDetails?.reasoningTokens,
|
||||
},
|
||||
...(!total || total.calls ? { calls: [...(total?.calls ?? []), call] } : {}),
|
||||
lastStep: call,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { BackgroundModelRole, ModelSlotName } from "../settings.ts";
|
||||
|
||||
/** One picker row: everything the UI shows about a model, projected from
|
||||
* pi-ai's catalog. Costs are USD per 1M tokens (the same catalog, the same
|
||||
* units core/prices.ts's generated table carries). */
|
||||
* units as Pi's server-side pricing calculator). */
|
||||
export interface ModelCatalogEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
+26
-12
@@ -9,7 +9,7 @@
|
||||
* the same ruling as wave 7's stages.embedding.)
|
||||
*
|
||||
* MONEY CHANNEL LAW: dollars are INFERRED here at read time via
|
||||
* core/prices.ts — never stored anywhere. An unpriced model contributes
|
||||
* an injected server-side Pi pricer — never stored anywhere. An unpriced model contributes
|
||||
* ZERO dollars but is counted in `unpricedRuns` so the UI can render
|
||||
* "— unpriced" honestly, never a silent zero. Tokens always sum.
|
||||
*
|
||||
@@ -18,13 +18,15 @@
|
||||
*/
|
||||
|
||||
import type { CallUsage, EmissionStats, ExtractionRunUsage } from "./contracts.ts";
|
||||
import { callCost, sumSteps } from "./prices.ts";
|
||||
import { sumSteps, isChatGPTPlan, type CallPricer } from "./prices.ts";
|
||||
|
||||
/** One pipeline stage's cumulative cost across every run. */
|
||||
export interface StageCost {
|
||||
/** ChatGPT plan stage-runs, excluded from API dollars and unpriced counts. */
|
||||
subscriptionRuns?: number;
|
||||
/** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
|
||||
dollars: number;
|
||||
/** Stage-runs whose model is missing from PRICES (render "—", never zero). */
|
||||
/** Stage-runs whose model is missing from Pi's catalog (render "—", never zero). */
|
||||
unpricedRuns: number;
|
||||
/** Runs that billed this stage. */
|
||||
runs: number;
|
||||
@@ -34,6 +36,7 @@ export interface StageCost {
|
||||
|
||||
/** One run in the recent-runs list (extraction or naming). */
|
||||
export interface RecentRun {
|
||||
subscriptionRuns?: number;
|
||||
startedAt: number;
|
||||
/** ISO wall-clock. */
|
||||
at: string;
|
||||
@@ -51,9 +54,10 @@ export interface RecentRun {
|
||||
|
||||
/** The cost pill's whole payload: stage totals + recent runs. */
|
||||
export interface GraphCostSummary {
|
||||
subscriptionRuns?: number;
|
||||
/** Inferred USD across all priced stages of all runs. */
|
||||
totalDollars: number;
|
||||
/** Runs where EVERY stage was unpriced (totalDollars excludes them). */
|
||||
/** Runs with no priced API stage and at least one unknown stage. */
|
||||
unpricedRuns: number;
|
||||
/** The billable pipeline stages. (Wave 7, owner ruling 2026-09-23:
|
||||
* the embedding stage is gone with the provider embedder; the
|
||||
@@ -88,10 +92,11 @@ const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tok
|
||||
|
||||
/** One stage's dollars over its steps; null when the model is unpriced
|
||||
* (all-or-nothing: a stage runs one model). */
|
||||
function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
|
||||
function stageDollars(model: string, steps: readonly CallUsage[], price: CallPricer): number | null {
|
||||
if (isChatGPTPlan(model) || steps.length === 0) return null;
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
const c = callCost(step, model);
|
||||
const c = price(step, model);
|
||||
if (c === null) return null;
|
||||
total += c;
|
||||
}
|
||||
@@ -101,6 +106,9 @@ function stageDollars(model: string, steps: readonly CallUsage[]): number | null
|
||||
/** Merge one step-set's tokens into an accumulating CallUsage. */
|
||||
function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
||||
const summed = sumSteps(steps);
|
||||
// Fold this before updating total writes, so an older write-bearing
|
||||
// aggregate with a missing split remains unknown even after fresh data.
|
||||
into.cacheWrite1hTokens = sumSteps([into, summed]).cacheWrite1hTokens;
|
||||
into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
|
||||
into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
|
||||
into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
|
||||
@@ -110,7 +118,7 @@ function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
|
||||
|
||||
/** Fold every usage channel into the pill's payload. Pure: same input,
|
||||
* same output — no clock, no fs, no store reads. */
|
||||
export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
export function graphCostSummary(input: CostSummaryInput, price: CallPricer): GraphCostSummary {
|
||||
const extraction = emptyStage();
|
||||
const audit = emptyStage();
|
||||
const runs: RecentRun[] = [];
|
||||
@@ -120,14 +128,16 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
// Per-stage: tokens always count; dollars only when priced.
|
||||
extraction.runs++;
|
||||
mergeTokens(extraction.tokens, stages.extractor.steps);
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
|
||||
if (extDollars === null) extraction.unpricedRuns++;
|
||||
const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps, price);
|
||||
if (isChatGPTPlan(stages.extractor.model)) extraction.subscriptionRuns = (extraction.subscriptionRuns ?? 0) + 1;
|
||||
else if (extDollars === null) extraction.unpricedRuns++;
|
||||
else extraction.dollars += extDollars;
|
||||
|
||||
audit.runs++;
|
||||
mergeTokens(audit.tokens, stages.auditor.steps);
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
|
||||
if (audDollars === null) audit.unpricedRuns++;
|
||||
const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps, price);
|
||||
if (isChatGPTPlan(stages.auditor.model)) audit.subscriptionRuns = (audit.subscriptionRuns ?? 0) + 1;
|
||||
else if (audDollars === null) audit.unpricedRuns++;
|
||||
else audit.dollars += audDollars;
|
||||
|
||||
// Run-level: null only when EVERY stage is unpriced.
|
||||
@@ -135,8 +145,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
const runDollars = allStages.every((d) => d === null)
|
||||
? null
|
||||
: allStages.reduce<number>((a, d) => a + (d ?? 0), 0);
|
||||
const subscriptionRuns = [stages.extractor, stages.auditor].filter((stage) => isChatGPTPlan(stage.model)).length;
|
||||
const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
|
||||
runs.push({
|
||||
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
||||
startedAt: run.startedAt,
|
||||
at: run.at,
|
||||
durationMs: run.durationMs,
|
||||
@@ -159,8 +171,10 @@ export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
|
||||
const capped = runs.slice(0, input.recentCap ?? 10);
|
||||
|
||||
// A run is "unpriced" when it billed nothing countable at all.
|
||||
const unpricedRuns = runs.filter((r) => r.dollars === null).length;
|
||||
const unpricedRuns = runs.filter((r) => r.dollars === null && (r.subscriptionRuns ?? 0) < 2).length;
|
||||
const subscriptionRuns = (extraction.subscriptionRuns ?? 0) + (audit.subscriptionRuns ?? 0);
|
||||
return {
|
||||
...(subscriptionRuns ? { subscriptionRuns } : {}),
|
||||
totalDollars: extraction.dollars + audit.dollars,
|
||||
unpricedRuns,
|
||||
stages: { extraction, audit },
|
||||
|
||||
@@ -48,6 +48,8 @@ export * from "./input-events.ts";
|
||||
export * from "./view.ts";
|
||||
export * from "./tool-parts.ts";
|
||||
export * from "./prices.ts";
|
||||
export * from "./context-usage.ts";
|
||||
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
|
||||
export * from "./cost-summary.ts";
|
||||
export * from "./fuzzy.ts";
|
||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||
|
||||
@@ -674,6 +674,57 @@ export function endsInDismissal(messages: readonly LhMessage[]): boolean {
|
||||
return last !== undefined && isDismissedResult(last);
|
||||
}
|
||||
|
||||
export type RequestCause = "foreground" | "background";
|
||||
export type ReconstructedCause = RequestCause | "ambiguous";
|
||||
export type NativeInputCause = "learner" | "automatic" | "neutral" | "unrecognizable";
|
||||
/** Native causes only: flattened user roles, IDs' prefixes and prose confer no permission. */
|
||||
export function nativeInputCause(message: AgentMessage, history: readonly AgentMessage[]): NativeInputCause {
|
||||
if (message.role === "user") return "learner";
|
||||
if (message.role === "system" || message.role === "assistant") return "neutral";
|
||||
if (message.role === "custom") {
|
||||
if (["probe-handoff", "subagent-steer", "subagent-abort"].includes(message.customType)) return "automatic";
|
||||
if (["practice-thinking", "practice-next"].includes(message.customType)) return "learner";
|
||||
return ["graph-context", "plan-block", "problem-dismissed", "compaction"].includes(message.customType) ? "neutral" : "unrecognizable";
|
||||
}
|
||||
if (message.role === "toolResult") {
|
||||
if (!(QUESTION_TOOLS as readonly string[]).includes(message.toolName) || message.isError || message.details == null || (message.details as { dismissed?: boolean }).dismissed) return "neutral";
|
||||
const details = message.details as Partial<QuestionAnswerData>;
|
||||
if (!["quiz", "ask", "explain"].includes(details.kind ?? "") || typeof details.questionText !== "string" || typeof details.dontKnow !== "boolean" || !Array.isArray(details.answers) || typeof details.answeredAt !== "string") return "unrecognizable";
|
||||
const calls = history.flatMap((m) => m.role === "assistant" ? m.content.filter((b) => b.type === "toolCall" && b.id === message.toolCallId) : []);
|
||||
return calls.length === 1 && calls[0]?.type === "toolCall" && calls[0].name === message.toolName ? "learner" : "unrecognizable";
|
||||
}
|
||||
return "unrecognizable";
|
||||
}
|
||||
/** Full persisted native record, including pre-compaction causes. Never guessed foreground. */
|
||||
export function reconstructRequestCause(messages: readonly LhMessage[]): ReconstructedCause {
|
||||
let inherited: ReconstructedCause = "ambiguous";
|
||||
let triggers: NativeInputCause[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
const history: AgentMessage[] = [];
|
||||
const resolve = (): ReconstructedCause => {
|
||||
if (triggers.includes("unrecognizable")) return "ambiguous";
|
||||
const lastLearner = triggers.lastIndexOf("learner"), lastAutomatic = triggers.lastIndexOf("automatic");
|
||||
if (lastLearner < 0 && lastAutomatic < 0) return inherited;
|
||||
if (lastAutomatic < 0) return "foreground";
|
||||
if (lastLearner < lastAutomatic) return "background";
|
||||
return "ambiguous";
|
||||
};
|
||||
for (const message of messages) {
|
||||
const cause = nativeInputCause(message, history);
|
||||
const id = (message as { id?: string }).id;
|
||||
if (id) {
|
||||
const value = JSON.stringify(message);
|
||||
if (seen.has(id)) { if (seen.get(id) !== value) triggers.push("unrecognizable"); continue; }
|
||||
seen.set(id, value);
|
||||
} else if (cause !== "neutral") triggers.push("unrecognizable");
|
||||
if (message.role === "assistant" && !(message.provider === "synthetic" && message.api === "faux" && message.model === "synthetic-conclude" && message.content.some((b) => b.type === "toolCall" && b.name === "conclude" && b.arguments?.outcome === "dismissed"))) {
|
||||
inherited = resolve(); triggers = [];
|
||||
} else if (cause !== "neutral") triggers.push(cause);
|
||||
history.push(message);
|
||||
}
|
||||
return resolve();
|
||||
}
|
||||
|
||||
export function needsContinuation(messages: readonly LhMessage[]): boolean {
|
||||
const last = messages[messages.length - 1];
|
||||
if (!last || (last as { role?: string }).role !== "toolResult") return false;
|
||||
|
||||
+39
-1738
File diff suppressed because it is too large
Load Diff
+50
-10
@@ -7,6 +7,10 @@ import {
|
||||
type TeachingAgent,
|
||||
} from "../agent/teacher-agent.ts";
|
||||
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
|
||||
import { chatGPTBackgroundAllowed, chatGPTScopeCurrent } from "../agent/chatgpt-bridge.ts";
|
||||
import { chatGPTStreamError } from "../agent/chatgpt-stream.ts";
|
||||
import { ChatGPTRequestCycle, type ChatGPTDispatchLifecycle } from "../agent/chatgpt-request-cycle.ts";
|
||||
import { reconstructRequestCause } from "../core/native-messages.ts";
|
||||
import { createJobRunner } from "../agent/subagents/runner.ts";
|
||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import type { ModelRole } from "../../config.ts";
|
||||
@@ -15,6 +19,7 @@ import {
|
||||
noteSteerVisible,
|
||||
reconcileUserSteers,
|
||||
registerLiveAgent,
|
||||
restoreUncommittedAutomatic,
|
||||
setJobRespawn,
|
||||
setTurnRunner,
|
||||
settleSteers,
|
||||
@@ -41,7 +46,7 @@ import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
||||
import { catalogContextLimits } from "./model-catalog.ts";
|
||||
import { catalogContextLimits, refreshConnectionCatalog } from "./model-catalog.ts";
|
||||
import { emit } from "../core/events.ts";
|
||||
import { settledTurnResponse } from "./turn-runs.ts";
|
||||
import { asAgentMessages } from "../core/native-messages.ts";
|
||||
@@ -201,6 +206,12 @@ export async function handleChatInput(
|
||||
// persist (nothing durable changes); wake-pipeline turns ride the same
|
||||
// path. The affected tier is strong — the conversation chain's own tier
|
||||
// (a chat.model override still resolves through it).
|
||||
if (conversation.provider === "chatgpt") {
|
||||
await refreshConnectionCatalog();
|
||||
if (input.type === "steers" && !chatGPTBackgroundAllowed()) {
|
||||
return Response.json({ error: "ChatGPT background use is off in this app. Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||
}
|
||||
}
|
||||
if (!providerKeyPresent(conversation.provider)) {
|
||||
const envelope = providerKeyMissingEnvelope(conversation.provider, ["strong"]);
|
||||
console.info(`[chat ${chatId}] turn refused — ${JSON.stringify(envelope)}`);
|
||||
@@ -216,12 +227,22 @@ export async function handleChatInput(
|
||||
// placement mirrors the BYOK/key gates — BEFORE appends and
|
||||
// beginTurn, no liveness effects — and the record re-reads after
|
||||
// because compaction appends the marker.
|
||||
const conversationEngine = piEngineForSpec(conversation);
|
||||
const callPurpose = { background: input.type === "steers" || (input.type === "continue" && reconstructRequestCause(record.messages) !== "foreground") };
|
||||
let requestCycle: ChatGPTRequestCycle | undefined;
|
||||
const lifecycle: ChatGPTDispatchLifecycle = {
|
||||
captured: (scope) => requestCycle?.captured(scope),
|
||||
beforeDispatch: (scope) => requestCycle?.beforeDispatch(scope),
|
||||
noDispatch: (kind) => requestCycle?.noDispatch(kind),
|
||||
};
|
||||
let conversationEngine: PiEngine;
|
||||
try { conversationEngine = piEngineForSpec(conversation, () => callPurpose.background, lifecycle); }
|
||||
catch { return Response.json({ error: conversation.provider === "chatgpt" ? chatGPTStreamError("model catalog") : "The selected model is unavailable." }, { status: 503 }); }
|
||||
const modelIdForCompaction = `${conversationEngine.model.provider}:${conversationEngine.model.id}`;
|
||||
await ensureCompacted(
|
||||
{
|
||||
chatStore: stores.chatStore,
|
||||
engine: conversationEngine,
|
||||
engine: piEngineForSpec(conversation, true),
|
||||
canStart: () => conversation.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||
modelId: modelIdForCompaction,
|
||||
kind: record.kind === "practice" ? "practice" : hasProbeDone(record.messages) ? "teach" : "probe",
|
||||
thresholdTokens: effectiveThresholdTokens(
|
||||
@@ -287,12 +308,14 @@ export async function handleChatInput(
|
||||
// ride this same write-ahead - the pump de-queued them at fire, so this
|
||||
// is the ONLY copy ever appended.)
|
||||
const merged = [...record.messages, ...appends];
|
||||
const stagedAutomatic = conversation.provider === "chatgpt" && input.type === "steers";
|
||||
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||
try {
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
||||
// in the record — flip their jobs now (the wake's steers ride this
|
||||
// write-ahead; the delivering turn may run for minutes).
|
||||
for (const appended of appends) {
|
||||
for (const appended of stagedAutomatic ? [] : appends) {
|
||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -310,6 +333,7 @@ export async function handleChatInput(
|
||||
outputTokens: usage.output,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheWriteTokens: usage.cacheWrite,
|
||||
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||
// Optional in pi's Usage — persisted as 0, never dropped.
|
||||
reasoningTokens: usage.reasoning ?? 0,
|
||||
});
|
||||
@@ -339,8 +363,19 @@ export async function handleChatInput(
|
||||
// enabled). The definitive toggleable set is TOGGLEABLE_TOOLS — the
|
||||
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
|
||||
// user: quiz/ask/fs/plan/probe_done are always-on).
|
||||
if (conversation.provider === "chatgpt") requestCycle = new ChatGPTRequestCycle({
|
||||
initialMessages: asAgentMessages(merged),
|
||||
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
|
||||
foregroundOwed: !callPurpose.background,
|
||||
purpose: callPurpose,
|
||||
scopeCurrent: chatGPTScopeCurrent,
|
||||
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
|
||||
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
|
||||
});
|
||||
const agentOpts: AgentBuildOptions = {
|
||||
engine,
|
||||
callPurpose,
|
||||
requestCycle,
|
||||
messages: asAgentMessages(merged),
|
||||
onUsage: collectUsage,
|
||||
toolToggles: effective.agent.tools,
|
||||
@@ -439,6 +474,7 @@ export async function handleChatInput(
|
||||
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
|
||||
const handle = runPiTurn({
|
||||
agent: constructed,
|
||||
requestCycle,
|
||||
// The coalescing window seam (COMPACTION LAW, 2026-10-09): tests
|
||||
// tighten it via LH_UPDATE_FLUSH_MS; production rides the 100ms
|
||||
// default in ui-stream.
|
||||
@@ -454,13 +490,14 @@ export async function handleChatInput(
|
||||
// route through here: they ride the error-result path and reach the
|
||||
// transcript directly (the retry context the model saw).
|
||||
onError: (error) => {
|
||||
if (conversation.provider === "chatgpt") return chatGPTStreamError(String(error));
|
||||
console.error(`[chat ${chatId}] turn error:`, error);
|
||||
return "An error occurred.";
|
||||
},
|
||||
onFinish: (turnMessages, isAborted) => {
|
||||
// Wave B: the record write is the agent's own final state —
|
||||
// the new messages are the slice past the merged base.
|
||||
const stamped = persistTurn(stores.chatStore, chatId, merged.length, turnMessages, isAborted);
|
||||
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
|
||||
// usage log append (idempotent per turn id) — the stats bar
|
||||
// refolds this on the client after the turn settles. The
|
||||
// provenance message is the turn's FIRST new assistant message.
|
||||
@@ -479,7 +516,7 @@ export async function handleChatInput(
|
||||
// PRACTICE (owner, 2026-10-04): practice sessions write nothing —
|
||||
// no extraction, no graph, no coverage (v1: the session itself
|
||||
// is the deliverable).
|
||||
if (record.kind !== "practice" && extractionEnabled(effective.graph)) {
|
||||
if ((!requestCycle || requestCycle.dispatched) && record.kind !== "practice" && extractionEnabled(effective.graph)) {
|
||||
// Fresh per-turn read of the graph section's chunk budget
|
||||
// (extraction-UX redesign, 2026-09-24): the trigger compares
|
||||
// against it and the pipeline's batches share it.
|
||||
@@ -520,18 +557,20 @@ export async function handleChatInput(
|
||||
// beneath-threshold record is a silent no-op. Skipped under
|
||||
// tests (the extraction precedent — scripted chats are
|
||||
// fixtures; the service itself is pinned directly).
|
||||
if (process.env.NODE_ENV !== "test")
|
||||
if (process.env.NODE_ENV !== "test" && (!requestCycle || requestCycle.dispatched))
|
||||
kickCompaction(() => {
|
||||
const fresh = effectiveSettings();
|
||||
const spec = resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, fresh.models.strong);
|
||||
if (!spec) return null;
|
||||
const kickEngine = piEngineForSpec(spec);
|
||||
if (!spec || (spec.provider === "chatgpt" && !chatGPTBackgroundAllowed())) return null;
|
||||
let kickEngine: PiEngine;
|
||||
try { kickEngine = piEngineForSpec(spec, true); } catch { return null; }
|
||||
const kickModelId = `${kickEngine.model.provider}:${kickEngine.model.id}`;
|
||||
const freshRecord = stores.chatStore.getChat(chatId);
|
||||
if (!freshRecord) return null;
|
||||
return {
|
||||
chatStore: stores.chatStore,
|
||||
engine: kickEngine,
|
||||
canStart: () => spec.provider !== "chatgpt" || chatGPTBackgroundAllowed(),
|
||||
modelId: kickModelId,
|
||||
kind: freshRecord.kind === "practice" ? "practice" : hasProbeDone(freshRecord.messages) ? "teach" : "probe",
|
||||
thresholdTokens: effectiveThresholdTokens(
|
||||
@@ -558,6 +597,7 @@ export async function handleChatInput(
|
||||
// must cost zero permanently-held slots beyond the one live view. The
|
||||
// turn itself is a server-side process (ruling 2026-09-25) — it never
|
||||
// needed this connection, only a view did.
|
||||
if (stagedAutomatic && requestCycle && !(await requestCycle.admission)) return Response.json({ error: "Automatic follow-up remains deferred in its queue." }, { status: 403 });
|
||||
return Response.json({ ok: true });
|
||||
} catch (err) {
|
||||
// The stream never started, so no onFinish will fire — clear the registry
|
||||
|
||||
@@ -89,6 +89,8 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
|
||||
/** Everything the service needs from its host (chat-handler wires the
|
||||
* production versions; tests inject). */
|
||||
export interface CompactionDeps {
|
||||
/** Eligibility only for NEW runs; structural waits still apply. */
|
||||
canStart?: () => boolean;
|
||||
chatStore: ChatStore;
|
||||
/** The chat's conversation engine — also the summarizer's. */
|
||||
engine: PiEngine;
|
||||
@@ -173,7 +175,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
||||
if (tokensBefore <= deps.thresholdTokens) return; // settled beneath while we waited
|
||||
|
||||
const cut = compactionCutIndex(messages);
|
||||
if (cut >= messages.length) return; // nothing to drop (defensive)
|
||||
if (cut <= 0 || cut >= messages.length) return; // no history can be dropped
|
||||
|
||||
// The previous compaction's kept boundary (chained compactions
|
||||
// summarize the old summary + what rode after it).
|
||||
@@ -238,6 +240,7 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
|
||||
outputTokens: usage.output,
|
||||
cacheReadTokens: usage.cacheRead,
|
||||
cacheWriteTokens: usage.cacheWrite,
|
||||
cacheWrite1hTokens: usage.cacheWrite1h ?? 0,
|
||||
reasoningTokens: usage.reasoning ?? 0,
|
||||
},
|
||||
],
|
||||
@@ -267,6 +270,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
|
||||
// injected summarizer.
|
||||
if (process.env.NODE_ENV === "test" && !deps.summarize) return;
|
||||
await awaitCompaction(chatId);
|
||||
if (deps.canStart?.() === false) return;
|
||||
const record = deps.chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
||||
@@ -291,7 +295,7 @@ export async function ensureCompacted(deps: CompactionDeps, chatId: string): Pro
|
||||
export function kickCompaction(buildDeps: () => CompactionDeps | null, chatId: string): void {
|
||||
if (state().inFlight.has(chatId)) return;
|
||||
const deps = buildDeps();
|
||||
if (!deps) return;
|
||||
if (!deps || deps.canStart?.() === false) return;
|
||||
const record = deps.chatStore.getChat(chatId);
|
||||
if (!record) return;
|
||||
if (projectedContextTokens(record.messages, deps.systemPromptTokens) <= deps.thresholdTokens) return;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
/** Per-launch desktop HTTP capability. This is not an OS-user malware
|
||||
* boundary and is not an OAuth credential. Browser-only mode is unchanged. */
|
||||
export function desktopAPIAuthorized(
|
||||
headers: Headers,
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
const expected = env.LH_DESKTOP_API_SECRET;
|
||||
if (env.LH_DESKTOP_SERVER !== "1" && !expected) return true;
|
||||
if (!expected || expected.length < 64) return false;
|
||||
const supplied = headers.get("x-learnharness-desktop");
|
||||
if (!supplied) return false;
|
||||
const actual = Buffer.from(supplied);
|
||||
const wanted = Buffer.from(expected);
|
||||
return actual.length === wanted.length && timingSafeEqual(actual, wanted);
|
||||
}
|
||||
+2
-1
@@ -11,6 +11,7 @@ import {
|
||||
} from "../core/graph-deletion.ts";
|
||||
import { extractionStatuses } from "../agent/subagents/extraction.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { callCost } from "./pricing.ts";
|
||||
|
||||
/**
|
||||
* Server-side graph entry for the home view (GET /api/graph). A PURE READ:
|
||||
@@ -41,7 +42,7 @@ export function graphCosts(): GraphCostSummary {
|
||||
.readSources()
|
||||
.filter((s) => s.extractions?.length)
|
||||
.map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
|
||||
return graphCostSummary({ chats, sources });
|
||||
return graphCostSummary({ chats, sources }, callCost);
|
||||
}
|
||||
|
||||
/** The graph's version stamp (session-deletion wave 6): the max mtime
|
||||
|
||||
@@ -43,3 +43,4 @@ export * from "./compaction.ts";
|
||||
export { runPiTurn, piTurnResponse, type PiTurnHandle, type PiTurnSpec } from "./pi/ui-stream.ts";
|
||||
export * from "./observability.ts";
|
||||
export * from "./model-catalog.ts";
|
||||
export { sessionCost } from "./pricing.ts";
|
||||
|
||||
+66
-5
@@ -66,7 +66,9 @@ import {
|
||||
import type { TurnInput } from "../core/input-events.ts";
|
||||
import { danglingQuestionOf, endsInDismissal, type LhMessage } from "../core/native-messages.ts";
|
||||
import { fireJobAbort, isJobLive } from "../agent/subagents/aborts.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { stores, effectiveSettings } from "./stores.ts";
|
||||
import { resolveConversationSpec } from "../core/settings-resolution.ts";
|
||||
import { chatGPTBackgroundAllowed, onChatGPTChange } from "../agent/chatgpt-bridge.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
import type { Agent } from "@earendil-works/pi-agent-core";
|
||||
|
||||
@@ -111,6 +113,8 @@ interface WakeState {
|
||||
pumping: Set<string>;
|
||||
/** Throttle bookkeeping for the stale-job sweep. */
|
||||
lastSweepAt: number;
|
||||
stopConnectionWatch?: () => void;
|
||||
automaticDeferred: WeakSet<Agent>;
|
||||
}
|
||||
|
||||
interface LhWakeGlobal {
|
||||
@@ -119,7 +123,7 @@ interface LhWakeGlobal {
|
||||
|
||||
function state(): WakeState {
|
||||
const g = globalThis as typeof globalThis & LhWakeGlobal;
|
||||
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0 });
|
||||
return (g.__lhWakePipeline ??= { queues: new Map(), liveAgents: new Map(), userSteers: new Map(), pumping: new Set(), lastSweepAt: 0, automaticDeferred: new WeakSet() });
|
||||
}
|
||||
|
||||
/** chat-handler wires this at module init: its adapter around handleChatRequest. */
|
||||
@@ -210,9 +214,18 @@ export function deliverJobResult(steer: SteerMessage): Promise<void> {
|
||||
* beats the loop's synchronous start-poll drain and the marker rides the
|
||||
* run's FIRST LLM call (the native equivalent of the old continuation
|
||||
* coalescing). */
|
||||
export function automaticFollowUpAllowed(chatId: string, agent?: Agent): boolean {
|
||||
if (agent && state().automaticDeferred.has(agent)) return false;
|
||||
const provider = agent?.state?.model?.provider ?? resolveConversationSpec(stores.chatStore.getChat(chatId)?.settings, effectiveSettings().models.strong)?.provider;
|
||||
return provider !== "chatgpt" || chatGPTBackgroundAllowed();
|
||||
}
|
||||
|
||||
function handOffEligible(chatId: string, agent: Agent): void {
|
||||
const queue = state().queues.get(chatId);
|
||||
if (queue && queue.length > 0) {
|
||||
// Keep automatic results in their owning registry. Never mix them into a
|
||||
// foreground batch without express account consent. Learner steers below
|
||||
// remain deliverable, including the first poll of an answer run.
|
||||
if (automaticFollowUpAllowed(chatId, agent) && queue && queue.length > 0) {
|
||||
for (const entry of queue) {
|
||||
if (entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
|
||||
agent.steer(entry.steer);
|
||||
@@ -290,9 +303,25 @@ export function reconcileUserSteers(chatId: string, persisted: readonly LhMessag
|
||||
/** Clear the live agent at settlement — MUST run before settleSteers so the
|
||||
* reconciliation's re-pump can never hand a marker to the dying agent. */
|
||||
export function unregisterLiveAgent(chatId: string): void {
|
||||
const agent = state().liveAgents.get(chatId);
|
||||
if (agent) state().automaticDeferred.delete(agent);
|
||||
state().liveAgents.delete(chatId);
|
||||
}
|
||||
|
||||
/** Restore exact registry objects after native drain but before dispatch.
|
||||
* The vehicle is disposable; result ownership/promises are not. */
|
||||
export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automaticIds: ReadonlySet<string>, retainedIds: ReadonlySet<string>): void {
|
||||
const s = state();
|
||||
s.automaticDeferred.add(agent);
|
||||
for (const entry of s.queues.get(chatId) ?? []) {
|
||||
if (entry.handedTo === agent && automaticIds.has(entry.steer.id)) entry.handedTo = undefined;
|
||||
}
|
||||
agent.clearSteeringQueue();
|
||||
for (const entry of s.userSteers.get(chatId) ?? []) {
|
||||
if (!retainedIds.has(entry.message.id)) { agent.steer(entry.message); entry.handedTo = agent; }
|
||||
}
|
||||
}
|
||||
|
||||
/** TURN SETTLEMENT RECONCILIATION (Wave C): the handler calls this AFTER
|
||||
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
|
||||
*
|
||||
@@ -419,6 +448,7 @@ async function pump(chatId: string): Promise<void> {
|
||||
const s = state();
|
||||
if (s.pumping.has(chatId)) return;
|
||||
s.pumping.add(chatId);
|
||||
let parked = false;
|
||||
try {
|
||||
for (let attempt = 0; ; ) {
|
||||
const queue = s.queues.get(chatId);
|
||||
@@ -452,6 +482,7 @@ async function pump(chatId: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
s.queues.set(chatId, stillPending);
|
||||
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
|
||||
|
||||
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
|
||||
// streaming turn takes every eligible marker; pi injects at the
|
||||
@@ -525,7 +556,8 @@ async function pump(chatId: string): Promise<void> {
|
||||
// hands them to that turn once the text-claim lands — until then,
|
||||
// settlement re-pumps and lands them right after it). The pause is
|
||||
// derived from the tail, so any user message un-holds by construction.
|
||||
if (endsInDismissal(fresh.messages)) {
|
||||
if (endsInDismissal(fresh.messages) || !automaticFollowUpAllowed(chatId)) {
|
||||
parked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -544,6 +576,15 @@ async function pump(chatId: string): Promise<void> {
|
||||
console.error(`[steer ${steers.map((x) => x.id).join(",")}] wake turn crashed:`, err);
|
||||
return -1;
|
||||
});
|
||||
if (status === 403) {
|
||||
// Consent may have changed after the last gate. Preserve completed
|
||||
// job/result ownership and wait for an explicit connection change.
|
||||
const back = s.queues.get(chatId) ?? [];
|
||||
back.push(...fire);
|
||||
s.queues.set(chatId, back);
|
||||
parked = true;
|
||||
return;
|
||||
}
|
||||
if (status === 409) {
|
||||
const back = s.queues.get(chatId) ?? [];
|
||||
back.push(...fire);
|
||||
@@ -573,10 +614,30 @@ async function pump(chatId: string): Promise<void> {
|
||||
// a live run are NOT stragglers (settleSteers owns their resurrection)
|
||||
// — restarting for them would just park in waitForIdle forever.
|
||||
const q = state().queues.get(chatId) ?? [];
|
||||
if (q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
||||
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
// Connection changes are the only wake for consent-deferred automatic work.
|
||||
// No retry timer, inference, or API-key fallback is started while consent is off.
|
||||
state().stopConnectionWatch?.();
|
||||
state().stopConnectionWatch = onChatGPTChange(() => {
|
||||
const s = state();
|
||||
for (const [chatId, agent] of s.liveAgents) {
|
||||
if (agent.state?.model?.provider !== "chatgpt" || automaticFollowUpAllowed(chatId, agent)) continue;
|
||||
const consumed = new Set(agent.state.messages.map((m) => (m as { id?: string }).id));
|
||||
const pending = (s.queues.get(chatId) ?? []).filter((entry) => entry.handedTo === agent && !consumed.has(entry.steer.id));
|
||||
if (pending.length === 0) continue;
|
||||
agent.clearSteeringQueue();
|
||||
for (const entry of pending) entry.handedTo = undefined;
|
||||
for (const entry of s.userSteers.get(chatId) ?? []) if (!consumed.has(entry.message.id)) entry.handedTo = undefined;
|
||||
handOffEligible(chatId, agent); // only unconsumed learner input; never replay consumed steers
|
||||
}
|
||||
for (const chatId of s.queues.keys()) {
|
||||
if (automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) void pump(chatId);
|
||||
}
|
||||
});
|
||||
|
||||
/** Flip the job to its final status now that the lesson has resumed. */
|
||||
function finalizeJob(jobId: string): void {
|
||||
const job = stores.jobStore.getJob(jobId);
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
validateKeyShape,
|
||||
} from "../core/auth-keys.ts";
|
||||
import { authStore } from "../store/auth.ts";
|
||||
import { chatGPTConnected } from "../agent/chatgpt-bridge.ts";
|
||||
|
||||
// The once-per-process boot record — globalThis anchor (HMR re-imports the
|
||||
// module; the snapshot must not re-run and misread fill-gapped vars as real
|
||||
@@ -200,6 +201,7 @@ export function keyValue(id: string): string | undefined {
|
||||
* (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take
|
||||
* no key at all: nothing to gate, present by definition. */
|
||||
export function providerKeyPresent(provider: string): boolean {
|
||||
if (provider === "chatgpt") return chatGPTConnected();
|
||||
const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider);
|
||||
return info ? keyValue(info.envVar) !== undefined : true;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai";
|
||||
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts";
|
||||
|
||||
export async function refreshConnectionCatalog(): Promise<void> {
|
||||
await refreshChatGPTModels(piModels().getModels("openai").filter((m): m is Model<"openai-responses"> => m.api === "openai-responses"));
|
||||
}
|
||||
function connectionModel(provider: string, id: string): Model<Api> | undefined {
|
||||
return provider === "chatgpt" ? chatGPTModel(id) : piModels().getModel(provider, id);
|
||||
}
|
||||
function connectionProviderIds(): string[] { return [...piModels().getProviders().map((p) => p.id), "chatgpt"]; }
|
||||
import { settingsStore } from "../store/settings.ts";
|
||||
import { effectiveSettings } from "./stores.ts";
|
||||
import { keyValue } from "./keys.ts";
|
||||
@@ -126,7 +135,7 @@ export function catalogProviders(): CatalogProvider[] {
|
||||
id: p.id,
|
||||
envVar: p.envVar,
|
||||
keyPresent: keyValue(p.envVar) !== undefined,
|
||||
})).sort((a, b) => a.id.localeCompare(b.id));
|
||||
})).concat([{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** The providers whose key is present — the picker federates exactly
|
||||
@@ -173,6 +182,7 @@ export function catalogContextLimits(): Map<string, number> {
|
||||
index.set(`${provider.id}:${model.id}`, model.contextWindow);
|
||||
}
|
||||
}
|
||||
for (const model of chatGPTModels()) index.set(`chatgpt:${model.id}`, model.contextWindow);
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -187,7 +197,8 @@ export function modelCatalog(): ModelCatalogResponse {
|
||||
.filter((p) => keyed.has(p.id))
|
||||
.flatMap((p) => piModels().getModels(p.id))
|
||||
.map(toModelDto)
|
||||
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
||||
.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)))
|
||||
.concat(chatGPTModels().map(toModelDto));
|
||||
return {
|
||||
models,
|
||||
defaults: catalogDefaults(),
|
||||
@@ -209,13 +220,13 @@ export type SettingsValidation =
|
||||
* the model the picker writes against (settings.model, else config's
|
||||
* prober/teacher pin; one controller, so the two specs share it). */
|
||||
export function validateChatSettings(settings: ChatSettings): SettingsValidation {
|
||||
const providers = piModels().getProviders().map((p) => p.id);
|
||||
const providers = connectionProviderIds();
|
||||
if (settings.model) {
|
||||
const { provider, id } = settings.model;
|
||||
if (!providers.includes(provider)) {
|
||||
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
|
||||
}
|
||||
if (!piModels().getModel(provider, id)) {
|
||||
if (!connectionModel(provider, id)) {
|
||||
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
|
||||
}
|
||||
}
|
||||
@@ -239,13 +250,13 @@ export function validateModelsSettings(settings: ModelsSettings): SettingsValida
|
||||
}
|
||||
|
||||
function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: string; id: string }; thinkingLevel?: ThinkingLevel }): SettingsValidation {
|
||||
const providers = piModels().getProviders().map((p) => p.id);
|
||||
const providers = connectionProviderIds();
|
||||
if (slot.model) {
|
||||
const { provider, id } = slot.model;
|
||||
if (!providers.includes(provider)) {
|
||||
return { ok: false, error: `Unknown provider "${provider}" — configured: ${providers.join(", ")}` };
|
||||
}
|
||||
if (!piModels().getModel(provider, id)) {
|
||||
if (!connectionModel(provider, id)) {
|
||||
return { ok: false, error: `No model "${id}" in the ${provider} catalog` };
|
||||
}
|
||||
}
|
||||
@@ -267,7 +278,7 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
|
||||
* or the level already supported — the existence gate rejects unknown
|
||||
* models separately, so this only ever sees real ones in practice). */
|
||||
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
|
||||
const model = piModels().getModel(provider, id);
|
||||
const model = connectionModel(provider, id);
|
||||
return model ? clampThinkingLevel(model, level) : level;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { chatStore } from "../store/chats.ts";
|
||||
import { graphRevision } from "./graph.ts";
|
||||
import { catalogContextLimits } from "./model-catalog.ts";
|
||||
import { listJobs } from "./jobs.ts";
|
||||
import { jobCost } from "./pricing.ts";
|
||||
import type { JobRecord } from "../core/contracts.ts";
|
||||
|
||||
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
|
||||
@@ -36,6 +37,7 @@ export { chatStore } from "../store/chats.ts";
|
||||
* model is missing from the catalog (the blob then renders no context
|
||||
* line — never a fake window). */
|
||||
export interface ActivityJob extends JobRecord {
|
||||
costUsd: number | null;
|
||||
title: string;
|
||||
contextLimit: number | null;
|
||||
}
|
||||
@@ -56,9 +58,8 @@ export interface ActivityExtraction {
|
||||
|
||||
/** The dock's whole world in one payload: every running process, every view.
|
||||
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
|
||||
* the blob disappears when the process ends. Cost math is NOT done here:
|
||||
* raw usage rides on the records and the client folds it with the pure
|
||||
* helpers in core/prices.ts (transport stays thin). */
|
||||
* the blob disappears when the process ends. Costs are joined here through
|
||||
* the server-side Pi pricing adapter; clients never reconstruct rates. */
|
||||
/** The dock's whole world + the graph's version stamp (wave 6 session
|
||||
* deletion): the payload carries graphRevision (max mtime across the
|
||||
* graph data files — see server/graph.ts) so any view can detect "the
|
||||
@@ -78,6 +79,7 @@ export function activityFeed(): {
|
||||
if (job.status === "running") {
|
||||
jobs.push({
|
||||
...job,
|
||||
costUsd: jobCost(job),
|
||||
title: chat.title ?? chat.id,
|
||||
contextLimit: job.usage ? (limits.get(job.usage.model) ?? null) : null,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-c
|
||||
import type { SteerMessage } from "../../core/contracts.ts";
|
||||
import { noteSteerVisible } from "../jobs.ts";
|
||||
import { ToolCallIdUniquifier } from "./call-ids.ts";
|
||||
import type { ChatGPTRequestCycle } from "../../agent/chatgpt-request-cycle.ts";
|
||||
|
||||
/**
|
||||
* THE NATIVE WIRE (Wave D, owner-approved 2026-09-26): one agent turn, streamed
|
||||
@@ -71,6 +72,7 @@ export type TurnWireEvent =
|
||||
| { type: "error"; errorText: string };
|
||||
|
||||
export interface PiTurnSpec {
|
||||
requestCycle?: ChatGPTRequestCycle;
|
||||
/** The per-turn agent (stateless engine — built from the record). */
|
||||
agent: Agent;
|
||||
/** The handler's error-masking policy for LOOP-level failures (the
|
||||
@@ -117,7 +119,7 @@ export interface PiTurnHandle {
|
||||
}
|
||||
|
||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100 } = spec;
|
||||
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
||||
|
||||
let settled = false;
|
||||
@@ -248,11 +250,24 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
settleWith();
|
||||
}
|
||||
};
|
||||
agent.subscribe(handle);
|
||||
requestCycle?.bindRelease(handle);
|
||||
agent.subscribe((event) => {
|
||||
if (!requestCycle?.hold(event)) handle(event);
|
||||
});
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await agent.continue();
|
||||
do {
|
||||
await agent.continue();
|
||||
if (!requestCycle) break;
|
||||
const denied = requestCycle.deniedBeforeDispatch;
|
||||
const outcome = requestCycle.finishNativeCycle();
|
||||
if (denied) agent.state.messages = outcome.messages;
|
||||
if (outcome.action !== "resumeForeground") {
|
||||
handle({ type: "agent_end", messages: [...agent.state.messages] });
|
||||
break;
|
||||
}
|
||||
} while (!sawAbort);
|
||||
} catch (err) {
|
||||
// A loop-level failure (not a provider error — those ride the
|
||||
// stream as stopReason "error"): surface, close, persist. If the
|
||||
@@ -318,6 +333,7 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
abort() {
|
||||
if (settled) return;
|
||||
sawAbort = true;
|
||||
requestCycle?.abort();
|
||||
agent.abort();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Read-time API-dollar estimates. Pi owns both rates and arithmetic.
|
||||
* Catalog resolution is key-independent; no provider dispatch/auth is needed.
|
||||
* Never mutate saved native messages: calculateCost mutates its usage.cost.
|
||||
*/
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||
import { isChatGPTPlan, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
|
||||
function catalogModel(id: string) {
|
||||
const colon = id.indexOf(":");
|
||||
if (colon < 0 || isChatGPTPlan(id)) return undefined;
|
||||
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||
}
|
||||
|
||||
export function callCost(call: Partial<CallUsage>, modelId: string): number | null {
|
||||
const model = catalogModel(modelId);
|
||||
if (!model || call.inputTokens === undefined || call.outputTokens === undefined) return null;
|
||||
const input = call.inputTokens;
|
||||
const output = call.outputTokens;
|
||||
const cacheRead = call.cacheReadTokens ?? 0;
|
||||
const cacheWrite = call.cacheWriteTokens ?? 0;
|
||||
const cacheWrite1h = call.cacheWrite1hTokens ?? 0;
|
||||
if ([input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => !Number.isFinite(v) || v < 0) || cacheWrite1h > cacheWrite) return null;
|
||||
// Old records discarded this split. Only price them if it cannot affect
|
||||
// the result; do not invent short-retention precision from a missing field.
|
||||
if (cacheWrite > 0 && call.cacheWrite1hTokens === undefined &&
|
||||
[model.cost, ...(model.cost.tiers ?? [])].some((rate) => rate.cacheWrite !== 2 * rate.input)) return null;
|
||||
const usage: Usage = {
|
||||
input, output, cacheRead, cacheWrite, cacheWrite1h,
|
||||
totalTokens: input + output + cacheRead + cacheWrite,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
const total = calculateCost(model, usage).total;
|
||||
return Number.isFinite(total) && total >= 0 ? total : null;
|
||||
}
|
||||
|
||||
export function turnCost(turn: TurnUsage): number | null {
|
||||
return foldTurnCost(turn, callCost);
|
||||
}
|
||||
export function sessionCost(record: Pick<ChatRecord, "turns">) {
|
||||
return foldSessionCost(record, callCost);
|
||||
}
|
||||
export function jobCost(job: Pick<JobRecord, "usage">): number | null {
|
||||
const usage = job.usage;
|
||||
if (!usage || usage.steps < 1) return null;
|
||||
const hasCalls = usage.calls?.length === usage.steps;
|
||||
if (!hasCalls && usage.steps > 1 && catalogModel(usage.model)?.cost.tiers?.length) return null;
|
||||
return foldJobCost(job, callCost);
|
||||
}
|
||||
Reference in New Issue
Block a user