Merge remote-tracking branch 'origin/main' into add-claude-code-support
# Conflicts: # package-lock.json # src/agent/models.ts
This commit is contained in:
+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