Merge current main into PR61 finishing branch

This commit is contained in:
amosblomqvist
2026-10-28 07:08:36 +02:00
170 changed files with 10528 additions and 2018 deletions
+13 -7
View File
@@ -1,4 +1,5 @@
import type { Model } from "@earendil-works/pi-ai";
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
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";
@@ -20,14 +21,15 @@ const validStamp = (s: Stamp) => s && [s.epoch, s.authorityRevision, s.revision]
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 runtimeConnected(): boolean { return desktopIPCConnected() || webSubscriptionsEnabled(); }
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 receive = (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?.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); }
@@ -43,8 +45,11 @@ function bridge(): BridgeState {
if (m.ok === true) call.resolve(m.value);
else call.reject(new Error(m.error ?? "ChatGPT connection failed."));
}
});
};
process.on("message", (raw: unknown) => { if (desktopIPCConnected()) receive(raw); });
onWebSubscriptionPacket("lh:chatgpt", (packet) => { if (webSubscriptionsEnabled()) receive(packet); });
process.on("disconnect", () => {
if (webSubscriptionsEnabled()) return;
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.")); }
@@ -60,9 +65,10 @@ function acceptStatus(state: BridgeState, value: StatusValue) {
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. */
/** Runtime-only: inherited desktop IPC or direct web owner. Never HTTP leases. */
export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:chatgpt", op, fields);
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}`;
@@ -74,7 +80,7 @@ export function chatGPTRequest<T>(op: "status" | "models" | "lease", fields: Rec
});
}
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 chatGPTConnected(): boolean { const s = chatGPTStatus(); return runtimeConnected() && 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; }
@@ -86,7 +92,7 @@ export function chatGPTScope(selectedModelId: string): AdmissionScope {
}
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);
return runtimeConnected() && 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");
@@ -108,7 +114,7 @@ export async function chatGPTDenial(scope: AdmissionScope | undefined, controlle
/** 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; }
if (!runtimeConnected()) { clearCatalog(state); return; }
const sequence = ++state.catalogRequest;
try {
const value = await chatGPTRequest<ModelsValue>("models");
+5 -7
View File
@@ -41,18 +41,16 @@ export function unregisterJobAbort(jobId: string): void {
* the liveness gate for the stale-job sweep: a "running" record with a
* live registration belongs to a loop this process owns (hung or slow, it
* is the one truth — never re-spawn beside it); without one the record is
* an orphan of a process death and recovers immediately. The span is
* loop-start to delivery-completion: the runner's unregistering finally
* runs only after deliver() resolves, by which point finalizeJob has
* flipped the record — so absence of registration is unambiguous. */
* an orphan of a process death and recovers immediately. The registration
* spans computation and cleanup only. Finished outcomes have independent,
* persisted delivery obligations; no worker waits for their consumption. */
export function isJobLive(jobId: string): boolean {
return registry().has(jobId);
}
/** Abort a running job's loop. Returns false when no controller is live —
* the job's loop is not executing (crashed, or in the delivering-result
* phase where the runner has already returned); the caller then owns the
* whole abort on the record side. */
* the job's loop is not executing. The endpoint separately refuses abort
* for a finished worker, even while its session delivery remains pending. */
export function fireJobAbort(jobId: string): boolean {
const controller = registry().get(jobId);
if (!controller) return false;
+13 -8
View File
@@ -28,7 +28,7 @@ import type { GraphStore } from "../../core/ports.ts";
import { emit } from "../../core/events.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
import type { LhMessage } from "../../core/native-messages.ts";
import { failedOutputCallIds, type LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
@@ -124,16 +124,20 @@ export function transcriptView(record: ChatRecord): string {
}
/** The extraction view under the teacher-prose ruling (owner, 2026-09-28):
* ONLY the teacher agent's text blocks — no learner messages, no
* thinking, no tool calls or results, no quiz blocks — each labeled with
* ONLY learner-facing teacher prose (text or send_to_user) — no learner
* messages, thinking, other tool calls/results, or quiz blocks — labeled with
* its ACTUAL message id (owner, 2026-10-13): the view lines ARE
* core/coverage.ts messageViewLines ("TEACHER [id] text"), the exact
* lines the coverage fingerprints hash, so the extractor can anchor
* every proposition to the passage it came from (the session submit's
* required messageId). The shared markdown engine stays reserved for the
* future user-facing export. */
function proseView(messages: ReadonlyArray<LhMessage>, title?: string): string {
const lines = messages.flatMap((message) => messageViewLines(message, message.id));
function proseView(
messages: ReadonlyArray<LhMessage>,
title?: string,
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds(messages),
): string {
const lines = messages.flatMap((message) => messageViewLines(message, message.id, failedOutputCalls));
const head = title && title.length > 0 ? `# ${title}\n\n` : "";
return head + lines.join("\n\n");
}
@@ -1113,6 +1117,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// stage runs (typed — the trigger skips, routes surface 503).
refuseIfStagesUnready();
const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
const failedOutputCalls = failedOutputCallIds(record.messages);
if (covered.uncovered.length === 0) {
console.info(`[extraction ${chatId}] nothing uncovered — no-op`);
return { nodes: 0, chains: 0 };
@@ -1148,14 +1153,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// fingerprints' messageViewLines ("TEACHER [id] text"), one
// shared seam.
const messages = batch.map((slot) => slot.message);
const view = proseView(messages, record.title);
const view = proseView(messages, record.title, failedOutputCalls);
// The batch's teacher-prose ids — the ONLY anchors this
// job's submit may name (validateSessionAnchors at the
// submit seam refuses anything else: ids from other
// batches — even real ones of this chat — other chats,
// and non-prose messages).
const batchIds = new Set(
messages.filter((m) => messageViewLines(m, m.id).length > 0).map((m) => m.id),
messages.filter((m) => messageViewLines(m, m.id, failedOutputCalls).length > 0).map((m) => m.id),
);
setPhase(jobKey, "queued", "chat", undefined, part);
const findings = await withExtractSlot(jobKey, async () => {
@@ -1188,7 +1193,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const batchStats = mergeFindings(chatId, findings, auditResult, { chatStore, graphStore }, {
foldedAt,
coverage: batch
.filter((slot) => messageViewLines(slot.message, slot.message.id).length > 0)
.filter((slot) => slot.tokens > 0)
.map((slot) => ({ chatId, fp: slot.fp, tokens: slot.tokens, foldedAt })),
});
// The layout ledger evolves with the merge (sync — the
+2 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { resolveSourcePath } from "../tools/fs-shared.ts";
import { piEngineFor, type PiEngine, type ThinkingLevel } from "../models.ts";
import { providerEventObserver, type ProviderEventObserver } from "../provider-events.ts";
import type { PdfResult, PdfToolInput } from "../../core/contracts.ts";
@@ -166,7 +167,7 @@ export async function runPdfReaderAgent(
): Promise<PdfResult> {
const headless = opts.headless ?? getHeadless();
const maxIterations = opts.maxIterations ?? MAX_ITERATIONS;
const pdfPath = input.path;
const pdfPath = resolveSourcePath(input.path);
// Deterministic upfront failure: unreadable / unparseable
// for renders — the brief never starts on a broken source.
+35 -27
View File
@@ -3,6 +3,7 @@ import type { SearchProvider } from "../../core/search.ts";
import {
accumulateJobUsage,
buildSubagentSteer,
isSubagentFailure,
piUsageToStep,
type DemoResult,
type DemoToolInput,
@@ -40,13 +41,13 @@ import { runVisualMaker } from "./visual-maker.ts";
* (the job tools' execute fires this via the spawn seam, fire-and-forget),
* updating the job's activity line on every iteration. On completion the
* runner builds the STEER MESSAGE (buildSubagentSteer) and hands it to
* `deliver`; the wake pipeline fires the server-side teacher turn and
* flips the job to done/error when the lesson has actually resumed.
* `deliver` without waiting. The worker's final outcome and pending
* delivery are saved together; the session owns delivery from then on.
*
* On failure the result is the failure shape (empty html/svg + "FAILED: …"
* notes; research: failure brief) — and it STILL steers, because the
* teacher must learn the outcome. If delivery itself fails, the job is
* marked error (the learner's activity card shows it).
* teacher must learn the outcome. Failed delivery starts stay quietly
* pending and never rewrite the worker's computation outcome.
*/
/**
@@ -67,9 +68,9 @@ export interface JobRunnerDeps {
chatStore: ChatStore;
jobStore: JobStore;
/**
* Hand a finished job's steer to the wake pipeline — resolves when the
* steer is delivered (the wake turn completed and the job status
* flipped); rejects when delivery fails unrecoverably.
* Hand a finished job's steer to the session delivery registry. The
* promise tracks durable marker acceptance, but the worker never waits
* on it. Its persisted pending obligation survives process restart.
*/
deliver: (steer: SteerMessage) => Promise<void>;
/** Settings-resolved engine per role (model slots wave, 2026-09-20):
@@ -96,8 +97,8 @@ export interface JobRunnerDeps {
export interface JobRunner {
/** Run one job's sub-agent loop to completion (background; never throws).
* The tool input rides along from dispatch (the spawn fires mid-stream,
* before the tool part is persisted — only crash-recovery re-spawns
* resolve the input from the transcript). */
* before the tool part is persisted). Recovery reads saved job input,
* with transcript fallback for legacy jobs. Delivery is not awaited. */
runJob(jobId: string, input?: JobToolInput): Promise<void>;
}
@@ -270,14 +271,15 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
}
}
/** Resolve the job's tool input: dispatch passes it directly; only a
* crash-recovery re-spawn (no input) falls back to scanning the
* transcript (by then onFinish has long persisted the part). */
/** Dispatch input wins, then the job's saved arguments. Legacy jobs
* without saved input fall back to the durable transcript, which may
* lack a call if the dispatching turn died before settlement. */
function resolveInput(
job: JobRecord,
input: JobToolInput | undefined,
): JobToolInput | undefined {
if (input) return input;
if (job.input !== undefined) return job.input as JobToolInput;
const chat = chatStore.getChat(job.chatId);
if (!chat) return undefined;
// Wave D: the record is native history — the scan reads the native
@@ -295,27 +297,28 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
}
/**
* The tail of every job: store the result, build the steer, hand it to
* the wake pipeline. The pipeline flips the status when the lesson has
* actually resumed — until then the job reads "running" so the client's
* activity card keeps polling and re-syncs once, at the end.
* Save the computation outcome and session delivery obligation together.
* Worker liveness ends after cleanup, not after a lesson consumes the result.
*/
async function finishJob(jobId: string, result: ResearchResult | DemoResult | VisualResult | PdfResult): Promise<void> {
const job = jobStore.getJob(jobId);
if (!job) return;
// Aborted mid-run or mid-delivery (abortJob flips the record before
// Aborted mid-run (abortJob flips the record before
// the loop unwinds): no result write, no steer — the abort path owns
// the record and queues the abort steer itself.
if (job.status !== "running") return;
jobStore.updateJob(jobId, { result, activity: "delivering result" });
const steer = buildSubagentSteer(job, result);
try {
await deliver(steer);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.error(`[job ${jobId}] steer delivery failed:`, err);
jobStore.updateJob(jobId, { status: "error", error: reason, activity: "delivery failed" });
}
const failed = isSubagentFailure(job.kind, result);
jobStore.updateJob(jobId, {
result,
status: failed ? "error" : "done",
activity: failed ? "failed" : "finished",
delivery: { kind: "result", status: "pending" },
});
// The saved result now belongs to the session. Do not keep the worker,
// abort registration, or temporary resources alive behind a question.
void deliver(buildSubagentSteer(job, result)).catch((err: unknown) => {
console.error(`[job ${jobId}] pending delivery:`, err);
});
}
/** Chat-record + input resolution shared by all three runners. */
@@ -343,7 +346,12 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
const { job } = ctx;
const input = (passedInput ?? resolveInput(job, passedInput)) as ResearchToolInput | undefined;
const question = typeof input?.question === "string" && input.question.trim() ? input.question : "(no question)";
if (!(typeof input?.question === "string" && input.question.trim())) {
jobStore.updateJob(jobId, { error: "research question unavailable" });
await finishJob(jobId, { brief: "Research failed: the research question is unavailable.", sources: [] });
return;
}
const question = input.question;
const context = typeof input?.context === "string" && input.context.trim() ? input.context : undefined;
const prompt = [
+10 -3
View File
@@ -1,4 +1,5 @@
import type { Api, Model, ModelAuth } from "@earendil-works/pi-ai";
import { onWebSubscriptionPacket, webSubscriptionRequest, webSubscriptionsEnabled } from "./subscription-runtime.ts";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionScope, type NativeSubscriptionSnapshot, type NativeSubscriptionStatus } from "../core/contracts/subscriptions.ts";
import type { AdmissionScope, NoDispatchKind } from "./chatgpt-request-cycle.ts";
@@ -18,6 +19,7 @@ export function nativeSubscriptionId(logicalId: string): NativeSubscriptionProvi
function desktopConnected(): 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 runtimeConnected(): boolean { return desktopConnected() || webSubscriptionsEnabled(); }
function notify(state: State) { for (const listener of state.listeners) { try { listener(); } catch { /* observer isolation */ } } }
function invalidate(state: State, providerId?: NativeSubscriptionProviderId) {
for (const [controller, owner] of state.controllers) if (!providerId || owner.providerId === providerId) controller.abort("all");
@@ -60,7 +62,11 @@ function bridge(): State {
if (message.ok) pending.resolve(message.value);
else pending.reject(new Error(message.error ?? "Subscription connection failed."));
});
onWebSubscriptionPacket("lh:subscriptions", (packet) => {
if (webSubscriptionsEnabled() && packet.snapshot) accept(state, packet.snapshot as NativeSubscriptionSnapshot);
});
process.on("disconnect", () => {
if (webSubscriptionsEnabled()) return;
invalidate(state);
for (const pending of state.pending.values()) { clearTimeout(pending.timer); pending.reject(new Error("The desktop connection closed.")); }
state.pending.clear(); notify(state);
@@ -70,6 +76,7 @@ function bridge(): State {
}
export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<string, unknown> = {}): Promise<T> {
const state = bridge();
if (webSubscriptionsEnabled()) return webSubscriptionRequest<T>("lh:subscriptions", op, fields);
if (!desktopConnected()) return Promise.reject(new Error("Subscription 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}`;
@@ -82,17 +89,17 @@ export function subscriptionRequest<T>(op: "snapshot" | "lease", fields: Record<
}
export async function refreshSubscriptionModels(): Promise<void> {
const state = bridge();
if (!desktopConnected()) { invalidate(state); return; }
if (!runtimeConnected()) { invalidate(state); return; }
try { accept(state, await subscriptionRequest<NativeSubscriptionSnapshot>("snapshot")); }
catch { invalidate(state); notify(state); }
}
export function nativeSubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus | undefined { return bridge().statuses.get(providerId); }
export function nativeSubscriptionConnected(providerId: NativeSubscriptionProviderId): boolean {
const status = nativeSubscriptionStatus(providerId);
return desktopConnected() && status?.available === true && status.connected && !!status.connectionId;
return runtimeConnected() && status?.available === true && status.connected && !!status.connectionId;
}
export function nativeSubscriptionBackgroundAllowed(providerId: NativeSubscriptionProviderId): boolean { return nativeSubscriptionConnected(providerId) && nativeSubscriptionStatus(providerId)?.allowBackgroundUse === true; }
export function nativeSubscriptionModels(): Model<Api>[] { return desktopConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModels(): Model<Api>[] { return runtimeConnected() ? [...bridge().models.values()].flat() : []; }
export function nativeSubscriptionModel(logicalId: string, modelId: string): Model<Api> | undefined { return nativeSubscriptionModels().find((m) => m.provider === logicalId && m.id === modelId); }
export function onNativeSubscriptionChange(listener: () => void): () => void { const state = bridge(); state.listeners.add(listener); return () => { state.listeners.delete(listener); }; }
export function subscriptionScope(providerId: NativeSubscriptionProviderId, selectedModelId: string): NativeSubscriptionScope {
+30
View File
@@ -0,0 +1,30 @@
/** Server-injected private web transport. The agent layer never imports a
* filesystem owner or server composition root. Desktop IPC remains separate. */
export type SubscriptionRuntimeChannel = "lh:chatgpt" | "lh:subscriptions";
export type SubscriptionRuntimePacket = Record<string, unknown>;
interface Runtime {
request?: <T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>) => Promise<T>;
listeners: Map<SubscriptionRuntimeChannel, Set<(packet: SubscriptionRuntimePacket) => void>>;
}
const KEY = Symbol.for("lh.webSubscriptionRuntime");
function runtime(): Runtime {
const global = globalThis as typeof globalThis & { [KEY]?: Runtime };
return global[KEY] ??= { listeners: new Map() };
}
export function configureWebSubscriptionRuntime(request: NonNullable<Runtime["request"]>): void { runtime().request = request; }
export function webSubscriptionsEnabled(): boolean {
return process.env.LH_DESKTOP_SERVER !== "1" && !process.env.LH_DESKTOP_API_SECRET && !!runtime().request;
}
export function onWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, listener: (packet: SubscriptionRuntimePacket) => void): void {
const state = runtime(); const listeners = state.listeners.get(channel) ?? new Set();
listeners.add(listener); state.listeners.set(channel, listeners);
}
export function publishWebSubscriptionPacket(channel: SubscriptionRuntimeChannel, packet: SubscriptionRuntimePacket): void {
for (const listener of runtime().listeners.get(channel) ?? []) {
try { listener(packet); } catch { /* observer isolation */ }
}
}
export function webSubscriptionRequest<T>(channel: SubscriptionRuntimeChannel, op: string, fields: Record<string, unknown>): Promise<T> {
if (!webSubscriptionsEnabled()) return Promise.reject(new Error("The web subscription owner is unavailable."));
return runtime().request!<T>(channel, op, fields);
}
+26 -12
View File
@@ -15,10 +15,11 @@ import {
type TeacherToolContext,
} from "./tools.ts";
import { practiceTools, type PracticeToolContext } from "./tools/practice.ts";
import { healToolRecord } from "./tools/heal.ts";
import { healToolRecord, withArgumentHeal } from "./tools/heal.ts";
import { sendToUserTool } from "./tools/send-to-user.ts";
import { quizInputObject, quizInputProblems } from "../core/question-core.ts";
import { QUESTION_TOOLS, type EnabledTools } from "../core/pending.ts";
import { toLlmMessages, nativeInputCause } from "../core/native-messages.ts";
import { toLlmMessages, nativeInputCause, PRACTICE_PROBLEM_TOOL } from "../core/native-messages.ts";
import type { ChatGPTRequestCycle } from "./chatgpt-request-cycle.ts";
import { usageWasAdmitted } from "./subscription-request-cycle.ts";
@@ -145,22 +146,27 @@ export interface AgentBuildOptions {
* belt-and-suspenders HALF for the mixed batch (a question + other calls
* in one message) that terminate-only semantics would let run on — the
* learner cannot answer while a turn is in flight (per-chat 409), so a
* cleanly-posed question MUST always end the turn. maxTurns is gone
* cleanly-posed question MUST always end the turn. Practice's problem pose
* has the same wait-for-the-learner contract, including mixed output/problem
* batches; failed poses must still retry. maxTurns is gone
* (owner ruling 2026-09-25: no maximum-agent-turns net on the teaching
* turn).
*/
function makeQuestionStopper() {
function makeQuestionStopper(kind: AgentKind) {
return (context: {
message: { content: Array<{ type: string; id?: string; name?: string }> };
toolResults: Array<{ toolCallId: string; isError?: boolean }>;
}): boolean => {
const called = new Set(
context.message.content
.filter((c) => c.type === "toolCall" && c.name !== undefined && (QUESTION_TOOLS as readonly string[]).includes(c.name!))
.filter((c) => c.type === "toolCall" && c.name !== undefined && (
(QUESTION_TOOLS as readonly string[]).includes(c.name) ||
(kind === "practice" && c.name === PRACTICE_PROBLEM_TOOL)
))
.map((c) => c.id!),
);
if (called.size === 0) return false;
// Stop only when every question call of this turn resolved cleanly.
// Stop only when every question/problem pose resolved cleanly.
for (const result of context.toolResults) {
if (called.has(result.toolCallId) && result.isError) return false;
}
@@ -174,7 +180,7 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// conversation-spec resolution as teaching); the cast keeps AgentKind
// ("practice" is not a ModelRole) honest about that.
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
const stopper = makeQuestionStopper();
const stopper = makeQuestionStopper(kind);
const systemPrompt = opts.promptSwitches
? composeInstructions(kind, opts.promptSwitches)
: composedInstructions[kind]!;
@@ -190,7 +196,16 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
systemPrompt,
model: engine.model,
thinkingLevel: engine.level,
tools,
// Conditional registry inclusion, like the graph-tool gates: when
// closed, neither the tool nor its usage instructions reach the model.
// Match Claude across providers (including OpenRouter), not the API
// transport. Explicit test registries remain caller-owned.
tools: [
...tools,
...(opts.tools === undefined && engine.model.id.toLowerCase().includes("claude")
? [withArgumentHeal(sendToUserTool)]
: []),
],
messages: opts.messages ?? [],
},
streamFn: engine.streamFn,
@@ -312,10 +327,9 @@ export function bareQuestionTools(): Record<string, AgentTool<any>> {
* from turn 1 of a kind:"practice" session — no prober→teacher split,
* no plan tools, no question tools. Its ENTIRE registry is
* problem + conclude (tools/practice.ts); the doctrine prompt
* (prompts/practice/) carries the method. The question-stop rule does
* not apply (problem rides terminate: true natively —
* QUESTION_TOOLS does not include it), so baseAgentOptions's stopper is
* a no-op here; it stays wired for uniformity.
* (prompts/practice/) carries the method. A successful problem pose ends
* the run, natively for a problem-only batch and via the shared stop hook
* for a mixed batch (for example, send_to_user + problem).
*/
export function createPracticeAgent(ctx: PracticeToolContext, opts: AgentBuildOptions = {}) {
const tools = opts.tools ?? practiceTools(ctx);
+1 -1
View File
@@ -78,7 +78,7 @@ function dispatch(
input: ResearchInput | DemoInput | VisualInput | PdfInput,
): string {
const existing = ctx.jobStore.findJobByToolCallId(ctx.chatId, toolCallId);
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId });
const job = existing ?? ctx.jobStore.createJob({ kind, chatId: ctx.chatId, toolCallId, input });
if (!existing) ctx.spawn(job, input);
return `Dispatched (job ${job.id}). ${ack}`;
}
+3 -2
View File
@@ -42,8 +42,9 @@ function probeConcluded(messages: readonly LhMessage[]): boolean {
* live), so the agent sees the fixed PROBE_DONE_ACK_TEXT — never an
* echo. details = {ok:true} only.
*
* The first probe_done in the transcript is also the construction seam:
* from that message on, every request builds the TEACHER agent.
* The first concluded probe_done (non-failed call plus matching successful
* result) is also the construction seam: from that result on, every request
* builds the TEACHER agent. Bare calls and refused results keep probing.
*/
export function probeDoneTool(ctx: ProbeDoneContext): Record<string, AgentTool<any>> {
return {
+36
View File
@@ -0,0 +1,36 @@
import { Type } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core";
import { SEND_TO_USER_TOOL } from "../../core/native-messages.ts";
import { serverExecute } from "../tool-execute.ts";
const sendToUserInput = Type.Object({
message: Type.String({
minLength: 1,
pattern: "\\S",
description: "The complete content to display to the learner. Markdown and LaTeX are supported.",
}),
});
/** Claude's documented verbatim mid-turn output channel. Availability is
* model-gated at the shared conversation-agent construction boundary;
* the native call remains in history, and the view renders its message. */
export const sendToUserTool: AgentTool<typeof sendToUserInput> = {
name: SEND_TO_USER_TOOL,
label: SEND_TO_USER_TOOL,
description:
"## REQUIRED OUTPUT CHANNEL — ALWAYS USE `send_to_user`\n\n" +
"**ALL learner-facing messages MUST be sent using `send_to_user`.** This includes explanations, teaching, " +
"feedback, corrections, hints, introductions, progress messages, and final responses. " +
"There is no exception for short messages or the end of a turn.\n\n" +
"Ordinary response text may be converted into summarized thinking instead of reaching the learner. " +
"**Never rely on ordinary response text to communicate with the learner. Thinking is not delivery.**\n\n" +
"Put the complete message in `message`. Markdown and LaTeX are supported. " +
"Do not duplicate it in ordinary response text.\n\n" +
"Question content still belongs in the appropriate question tool; send any accompanying teaching or " +
"commentary through `send_to_user` before calling that tool.\n\n" +
"This tool does not pause or end your turn. Continue with other tools when needed. " +
"After delivering your final message, end the turn without additional ordinary response text.",
parameters: sendToUserInput,
executionMode: "sequential",
execute: serverExecute(SEND_TO_USER_TOOL, sendToUserInput, () => "Message received for display."),
};
+57
View File
@@ -0,0 +1,57 @@
/** Read-only provenance for historical question cards. Never persist this
* projection: answer identities, ordering, grading, and learner text remain
* the recorded facts; only model-authored display text comes from the call. */
import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts";
import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts";
import { repairMathForDisplay } from "./math-delims.ts";
export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData {
if (answer.dismissed) return answer;
const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined;
const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined;
const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined);
if (!source) return answer;
const projected = { ...answer, questionText: source.question };
if (quiz) projected.explanation = quiz.explanation;
if (explain) projected.committed = explain.correctAnswer;
if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected;
const original = normalizeOptions(source.options);
const saved = answer.options;
const bindings = new Map<number, number>(); // saved position -> original position
const used = new Set<number>();
const bindUnique = (candidates: number[][]) => {
for (let si = 0; si < saved.length; si++) {
if (bindings.has(si)) continue;
const possible = candidates[si]!.filter((oi) => !used.has(oi));
if (possible.length !== 1) continue;
const oi = possible[0]!;
// Two saved rows claiming the same source are ambiguous, even if
// each row independently has just one candidate.
if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue;
bindings.set(si, oi);
used.add(oi);
}
};
// Selected-answer values are stable identities when the record retained
// them. Do not normalize those values, recompute a shuffle, or regrade.
bindUnique(saved.map((option) => {
const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value);
return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []);
}));
const recoveredLabels = original.map((o) => repairMathForDisplay(o.label));
bindUnique(saved.map((option) => original.flatMap((o, oi) =>
o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : [])));
// A bijection with one remaining pair has no ordering ambiguity. With
// multiple unmatched rows, keep their snapshots rather than guess.
if (saved.length === original.length && bindings.size === saved.length - 1) {
const si = saved.findIndex((_, i) => !bindings.has(i));
const oi = original.findIndex((_, i) => !used.has(i));
bindings.set(si, oi);
}
projected.options = saved.map((option, si) => {
const oi = bindings.get(si);
return oi === undefined ? option : { ...option, label: original[oi]!.label };
});
return projected;
}
+3 -3
View File
@@ -272,9 +272,9 @@ export function newCompactionMarker(summary: string, details: CompactionDetails)
};
}
/** The ACTIVE compaction marker: the newest one in the record (chained
* compactions — the last one rules; older markers fall inside the
* dropped span and never render). */
/** The ACTIVE compaction marker: the newest one with a retained-message
* id. Its summary supersedes older markers even when their stored
* positions remain inside its retained tail. */
export function activeCompactionOf(messages: readonly LhMessage[]): (LhMarkerMessage & { details: CompactionDetails }) | null {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
+215
View File
@@ -0,0 +1,215 @@
/** Source-preserving boundaries for tool-authored Markdown. Recovery never
* mutates input, reserializes Markdown, or enters protected literal content. */
import katex from "katex";
import { fromMarkdown } from "mdast-util-from-markdown";
import { mathFromMarkdown } from "mdast-util-math";
import { math } from "micromark-extension-math";
export type ContentSpan =
| { kind: "prose" | "code"; raw: string }
| { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean };
const blankLine = /^\r?\n[ \t]*\r?\n/;
const escaped = (text: string, i: number) => {
let n = 0; while (i > 0 && text[--i] === "\\") n++;
return n % 2 === 1;
};
const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; };
type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] };
/** Use the rendering stack's actual CommonMark+math grammar for literal
* ranges, including nested quotes/lists and implicit container endings.
* Keep source offsets: never serialize the AST back into Markdown. */
function literalRanges(text: string): Array<{ start: number; end: number }> {
const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })];
const ranges: Array<{ start: number; end: number }> = [];
while (pending.length) {
const node = pending.pop()!;
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset, end = node.position?.end.offset;
if (start !== undefined && end !== undefined) ranges.push({ start, end });
} else if (node.children) pending.push(...node.children);
}
return ranges.sort((a, b) => a.start - b.start);
}
/** Mirrors the app's native dollar-run and legacy-delimiter conventions,
* with code and blank paragraphs as inline boundaries. */
export function readContentSpans(text: string): ContentSpan[] {
const spans: ContentSpan[] = [];
const literals = literalRanges(text);
let literal = 0, i = 0, plain = 0;
const opaque = (end: number) => {
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end;
};
while (i < text.length) {
while (literal < literals.length && literals[literal]!.end <= i) literal++;
const barrier = literals[literal]?.start ?? Infinity;
if (i >= barrier) { opaque(literals[literal]!.end); continue; }
let open = "", close = "", display = false, after = -1, at = -1;
if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) {
open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\[";
for (let j = i + 2; j < text.length - 1; j++) {
if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break;
if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; }
}
} else if (text[i] === "$" && !escaped(text, i)) {
const size = runLength(text, i, "$"); open = "$".repeat(size);
const eol = text.indexOf("\n", i + size);
const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$");
if (display) {
let start = eol;
while (start >= 0 && start < text.length) {
start++;
const next = text.indexOf("\n", start);
const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next));
if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; }
start = next;
}
} else {
for (let j = i + size; j < text.length;) {
if (j >= barrier || blankLine.test(text.slice(j))) break;
if (text[j] === "$" && !escaped(text, j)) {
const n = runLength(text, j, "$");
if (n === size) { at = j; close = open; after = j + n; break; }
j += n;
} else j++;
}
}
}
if (at >= 0) {
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display });
i = plain = after;
} else i += open.length || 1;
}
if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) });
return spans;
}
export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string {
return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join("");
}
const knownCommands = new Map<string, boolean>();
/** Probe only a command name, never arbitrary content. Missing-argument errors
* still identify a known command. No trust, macros, IO, or persistent TeX state. */
export function knownTexCommand(name: string): boolean {
if (!/^[A-Za-z]+$/.test(name)) return false;
const cached = knownCommands.get(name); if (cached !== undefined) return cached;
let known = true;
try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); }
catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); }
knownCommands.set(name, known);
return known;
}
export function canParseTex(body: string, display: boolean): boolean {
try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; }
catch { return false; }
}
export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number }
export function numericPercentEscape(inner: string, percent: number): boolean {
let start = percent; while (start > 0 && inner[start - 1] === "\\") start--;
if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false;
const tail = inner.slice(percent + 1);
// A final source-formatting LF before the closing fence is not a next
// equation row. A meaningful following line or comment text remains literal.
if (/^[\s.)\]}]*$/.test(tail)) return true;
return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail);
}
/** TeX boundaries shared by document decoding and command recovery. Native
* rows are literal; consistently extra-escaped environment pairs supply an
* explicit escaping factor for both commands AND row separators. */
export function texRanges(inner: string): TexRange[] {
const ranges: TexRange[] = [];
const stack: Array<{ name: string; start: number; factor: number }> = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === "%" && !escaped(inner, i)) {
let start = i; while (start > 0 && inner[start - 1] === "\\") start--;
const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor;
const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1;
const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end);
if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; }
const newline = inner.indexOf("\n", i);
const end = newline < 0 ? inner.length : newline + 1;
ranges.push({ start: i, end, kind: "comment" }); i = end; continue;
}
if (inner[i] !== "\\") { i++; continue; }
let end = i + 1; while (inner[end] === "\\") end++;
const count = end - i;
const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end));
if (marker) {
const factor = count % 2 === 1 ? 1 : count;
if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor });
else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) {
const open = stack.pop()!;
ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor });
}
i = end + marker[0].length; continue;
}
// KaTeX's substack macro expands its braced argument into subarray:
// these are row separators even without literal begin/end markers.
const substack = /^substack\s*\{/.exec(inner.slice(end));
if (substack) {
let depth = 1, j = end + substack[0].length;
for (; j < inner.length && depth > 0; j++) {
if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; }
if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; }
}
if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count });
}
i = end;
}
return ranges.sort((a, b) => a.start - b.start);
}
export function outsideTexComments(body: string, transform: (part: string) => string): string {
let from = 0; const out: string[] = [];
for (const range of texRanges(body).filter(r => r.kind === "comment")) {
out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end;
}
out.push(transform(body.slice(from))); return out.join("");
}
/** Document escapes are not arbitrary string decoding. Complete known TeX
* words win over the prefix `\\n`; code is never inspected or rewritten. */
export function recoverDocumentLayout(text: string): string {
const spans = readContentSpans(text);
const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000");
const declared = new Set([
...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g),
...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g),
].map(m => m[1]!));
const knownN = (name: string) => declared.has(name) || knownTexCommand(name);
const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)];
const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!));
const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 &&
[...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!)));
if (!structural) return text;
const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => {
if (slashes.length % 2 === 0 || knownN(name)) return whole;
const word = part.slice(0, offset).split(/\s/).at(-1) ?? "";
if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole;
return slashes.slice(0, -1) + "\n" + name.slice(1);
});
const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) }));
let out = "";
for (let i = 0; i < projected.length; i++) {
const { span, body } = projected[i]!;
if (span.kind !== "math") { out += body; continue; }
// Decoding document layout can expose a flow opener whose close was
// written on the formula line. Frame BOTH ends; otherwise Markdown
// consumes the closing dollars as TeX and introduces a rendering error.
if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) {
if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n";
out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close;
const next = projected[i + 1];
if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n";
} else out += span.open + body + span.close;
}
return out;
}
+28 -32
View File
@@ -21,6 +21,12 @@ export interface JobRecord {
chatId: string;
/** The tool part this job fulfills. */
toolCallId: string;
/** Dispatch-time arguments, saved before spawn. Older jobs resolve from history. */
input?: unknown;
/** Session-owned delivery, independent of the worker's terminal outcome.
* Absent on legacy records: old terminal jobs must never be replayed. */
delivery?: { kind: "result" | "abort"; status: "pending" | "delivered" };
/** Worker computation only; a pending delivery does not keep it running. */
status: JobStatus;
/** Latest human-readable activity line, e.g. `web_search: "sigma.js v3"`. */
activity: string;
@@ -148,42 +154,32 @@ export function accumulateJobUsage(
* sourced briefs) — tracking a live activity line in the job record.
* ONE central client poll of GET /api/jobs?chatId=… (in Chat) drives every
* job surface: the transcript dispatch cards (live activity by
* toolCallId, quiet one-liner after delivery), the sub-agents sidebar
* toolCallId, quiet one-liner after worker completion), the sub-agents sidebar
* panel, and the single re-sync when a job settles.
*
* 3. On completion the runner builds a STEER MESSAGE (buildSubagentSteer)
* and hands it to the wake pipeline (src/server/jobs.ts): a user-role
* result-carrying native marker — the content is what the MODEL needs
* (the research brief, the builder's notes), plus a data-subagent-artifact
* part with what the UI renders (the full result: brief + sources /
* SVG / demo HTML). Provider conversion drops data parts, so artifact
* payloads never enter the teacher's context. metadata.subagentResult
* identifies the job; the UI renders these as sub-agent result cards,
* NEVER as learner interjections.
* 3. Dispatch saves input on the job before spawn. On completion the
* runner saves result + terminal worker status + pending delivery in
* one job update, then queues buildSubagentSteer WITHOUT waiting for
* delivery. The native custom marker's content is model-facing text;
* details carry the full artifact payload for the UI. Worker resources
* are released even if a question keeps the result pending indefinitely.
* Only computing workers appear in Background agents or accept abort.
*
* 4. The wake pipeline delivers the steer by firing a SERVER-SIDE teacher
* turn through the same turn composition as a client request
* (handleChatRequest) — write-ahead persist, turn liveness, extraction
* trigger: one code path. A per-chat serialized queue: a steer waits
* while a turn is in flight OR a question tool part is pending (an
* unanswered client-side tool call breaks provider conversion — the
* steer fires right after the learner's continuation), then fires; a 409
* (a client turn raced in) retries. Delivery is idempotent per jobId, so
* a crashed wake can be re-entered by the stale-job recovery sweep. The
* job's status flips to done/error only AFTER the wake turn completes —
* a polling client that sees the flip re-syncs once and gets the
* finished record.
* 4. Session delivery owns one registry entry per deterministic steer ID,
* including while a wake is in flight or a 409/403 returns its cargo.
* A live agent takes it via pi steering; registration restores saved
* pending jobs before the answer run's first poll. Otherwise the idle
* wake uses the normal turn composition and respects pending questions,
* dismissal, and background consent. A saved transcript marker completes
* delivery; HTTP 200 or streamed visibility alone does not.
*
* CONTINUATION COALESCING: the idle window above can starve — a
* quiz-chaining teacher ends every turn with a new pending check, so
* the gates never stay open long enough for a wake to fire. The fix:
* when a client turn wins the turn registry, the handler CLAIMS every
* queued, undelivered steer (jobs.ts claimSteers) and appends it after
* the learner's answer in that very turn — conversion-safe, because the
* answered quiz carries its output. Every answer flushes pending
* steers immediately; the wake pump remains for turns that end with no
* pending question. The pump re-checks steerDelivered right before
* firing, so a claim racing the pump converges to exactly one delivery.
* Failed starts remain quietly pending until a learner run or restart
* recovery, without new timers, visible delivery errors, or worker-status
* changes. Existing 409 backoff and consent deferral remain. Accepted
* turns are never automatically replayed. Legacy running records with a
* result become finished/pending; legacy terminal records with no
* delivery metadata remain untouched. Session deletion discards pending
* deliveries rather than treating them as running work.
*
* 5. No pending job parts ever exist, so the composer never gates on jobs
* (the learner can interject mid-job; the steer queues behind their
+25 -27
View File
@@ -1,9 +1,9 @@
/**
* Extraction coverage — the pure core. THE TEACHER'S PROSE IS THE UNIT
* (owner ruling, 2026-09-28): the extractor sees ONLY the teacher agent's
* text blocks — no learner messages, no thinking, no tool calls or
* results, no quiz blocks. Everything else is INVISIBLE to coverage: it
* neither opens nor joins a run, so answering a quiz or editing a learner
* prose — ordinary text blocks and explicit send_to_user messages. No
* learner messages, thinking, other tool calls/results, or quiz blocks.
* Everything else is INVISIBLE to coverage: it neither opens nor joins a run, so answering a quiz or editing a learner
* message triggers nothing. "Covered" is by construction exactly "what
* the extractor saw": the fingerprint (sha256 of a message's view lines)
* and the extractor's prompt share ONE selection — the teacher prose.
@@ -22,33 +22,27 @@
import { createHash } from "node:crypto";
import type { CoverageEntry } from "./contracts/graph.ts";
import type { LhMessage } from "./native-messages.ts";
import { failedOutputCallIds, userFacingTextOf, type LhMessage } from "./native-messages.ts";
export type { CoverageEntry };
/** The user-facing text of a native message (string or text blocks). */
function textOf(content: unknown): string[] {
if (typeof content === "string") return content.trim() ? [content] : [];
if (!Array.isArray(content)) return [];
return content
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
.map((c) => c.text)
.filter((t) => t.trim().length > 0);
}
/**
* The extraction-relevant lines of ONE native message — the shared seam
* between the extractor's prompt and messageFingerprint (coverage).
* TEACHER PROSE ONLY (owner, 2026-09-28): an assistant message yields one
* line per text block; everything else (user messages, tool results,
* markers) yields NOTHING and is invisible to coverage. `mid` is the
* message id — part of the hash input, so the same prose in two messages
* line per learner-facing prose block; everything else (user messages,
* tool results, markers) yields NOTHING and is invisible to coverage.
* `mid` is the message id — part of the hash input, so the same prose in two messages
* is two slots (per-message coverage).
*/
export function messageViewLines(message: LhMessage, mid: string): string[] {
export function messageViewLines(
message: LhMessage,
mid: string,
failedOutputCalls: ReadonlySet<string> = failedOutputCallIds([message]),
): string[] {
const lines: string[] = [];
if ((message as { role?: string }).role !== "assistant") return lines;
for (const text of textOf((message as { content: unknown }).content)) {
for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) {
lines.push(`TEACHER [${mid}] ${text.trim()}`);
}
return lines;
@@ -62,9 +56,10 @@ export function messageViewLines(message: LhMessage, mid: string): string[] {
*/
export function teacherProseBlocks(messages: ReadonlyArray<LhMessage>): string[] {
const out: string[] = [];
const failedOutputCalls = failedOutputCallIds(messages);
for (const message of messages) {
if ((message as { role?: string }).role !== "assistant") continue;
for (const text of textOf((message as { content: unknown }).content)) out.push(text.trim());
for (const text of userFacingTextOf((message as { content: unknown }).content, failedOutputCalls)) out.push(text.trim());
}
return out;
}
@@ -79,9 +74,9 @@ export function viewTokens(lines: string[]): number {
* teacher prose, with the message id as the tag). Changes exactly when
* the teacher prose of the message changes — nothing else can re-open it.
*/
export function messageFingerprint(message: LhMessage): string {
export function messageFingerprint(message: LhMessage, failedOutputCalls?: ReadonlySet<string>): string {
const h = createHash("sha256");
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id)]));
h.update(JSON.stringify([(message as { role?: string }).role, messageViewLines(message, message.id, failedOutputCalls)]));
return h.digest("hex");
}
@@ -95,7 +90,12 @@ export interface CoverageSlot {
/** Fingerprint a whole transcript, in order (used by the trigger, the run,
* and the coverage view — one derivation everywhere). */
export function coverageSlots(messages: ReadonlyArray<LhMessage>): CoverageSlot[] {
return messages.map((message) => ({ message, fp: messageFingerprint(message), tokens: viewTokens(messageViewLines(message, message.id)) }));
const failedOutputCalls = failedOutputCallIds(messages);
return messages.map((message) => ({
message,
fp: messageFingerprint(message, failedOutputCalls),
tokens: viewTokens(messageViewLines(message, message.id, failedOutputCalls)),
}));
}
/** The covered-fingerprint set for one chat, from the ledger. */
@@ -131,16 +131,14 @@ export function coverageOf(
let uncoveredTokens = 0;
for (let i = 0; i < slots.length; i++) {
const slot = slots[i]!;
if (messageViewLines(slot.message, slot.message.id).length === 0) continue; // invisible
if (slot.tokens === 0) continue; // invisible (including failed output calls)
if (covered.has(slot.fp)) coveredTokens += slot.tokens;
else {
uncovered.push(i);
uncoveredTokens += slot.tokens;
}
}
const visibleCount = slots.length - slots.filter(
(s) => messageViewLines(s.message, s.message.id).length === 0,
).length;
const visibleCount = slots.filter((s) => s.tokens > 0).length;
return {
slots,
uncovered,
+90
View File
@@ -0,0 +1,90 @@
/** Nova-3 monolingual language codes, including documented locale aliases.
* Source: https://developers.deepgram.com/docs/models-languages-overview
* Multilingual is a separate mode; its supported language set differs.
*/
const NOVA_3_LANGUAGES = [
["Afrikaans", ["af", "af-ZA"]],
["Arabic", ["ar", "ar-AE", "ar-SA", "ar-QA", "ar-KW", "ar-SY", "ar-LB", "ar-PS", "ar-JO", "ar-EG", "ar-SD", "ar-TD", "ar-MA", "ar-DZ", "ar-TN", "ar-IQ", "ar-IR"]],
["Armenian", ["hy"]],
["Assamese", ["as", "as-IN"]],
["Belarusian", ["be"]],
["Bengali", ["bn"]],
["Bosnian", ["bs"]],
["Bulgarian", ["bg"]],
["Catalan", ["ca"]],
["Chinese (Cantonese, Traditional)", ["zh-HK"]],
["Chinese (Mandarin, Simplified)", ["zh", "zh-CN", "zh-Hans"]],
["Chinese (Mandarin, Traditional)", ["zh-TW", "zh-Hant"]],
["Croatian", ["hr"]],
["Czech", ["cs", "cs-CZ"]],
["Danish", ["da", "da-DK"]],
["Dutch", ["nl"]],
["English", ["en", "en-US", "en-AU", "en-GB", "en-IN", "en-NZ"]],
["Estonian", ["et"]],
["Finnish", ["fi"]],
["Flemish", ["nl-BE"]],
["French", ["fr", "fr-CA"]],
["Georgian", ["ka", "ka-GE"]],
["German", ["de"]],
["German (Switzerland)", ["de-CH"]],
["Greek", ["el"]],
["Gujarati", ["gu", "gu-IN"]],
["Hebrew", ["he"]],
["Hindi", ["hi"]],
["Hungarian", ["hu"]],
["Indonesian", ["id"]],
["Italian", ["it"]],
["Japanese", ["ja"]],
["Kannada", ["kn"]],
["Kazakh", ["kk", "kk-KZ"]],
["Korean", ["ko", "ko-KR"]],
["Latvian", ["lv"]],
["Lithuanian", ["lt"]],
["Macedonian", ["mk"]],
["Malay", ["ms"]],
["Marathi", ["mr"]],
["Mongolian", ["mn"]],
["Nepali", ["ne"]],
["Norwegian", ["no"]],
["Pashto", ["ps", "ps-AF"]],
["Persian", ["fa"]],
["Polish", ["pl"]],
["Portuguese", ["pt", "pt-BR", "pt-PT"]],
["Punjabi", ["pa", "pa-IN"]],
["Romanian", ["ro"]],
["Russian", ["ru"]],
["Serbian", ["sr"]],
["Slovak", ["sk"]],
["Slovenian", ["sl"]],
["Spanish", ["es", "es-419"]],
["Swedish", ["sv", "sv-SE"]],
["Tagalog", ["tl"]],
["Tamil", ["ta"]],
["Telugu", ["te"]],
["Thai", ["th", "th-TH"]],
["Turkish", ["tr", "tr-TR"]],
["Ukrainian", ["uk"]],
["Urdu", ["ur"]],
["Vietnamese", ["vi"]],
] as const;
export type DictationLanguage = "multi" | (typeof NOVA_3_LANGUAGES)[number][1][number];
export const DICTATION_LANGUAGES: readonly { code: DictationLanguage; label: string }[] = [
{ code: "multi", label: "Multilingual" },
...NOVA_3_LANGUAGES.flatMap(([name, codes]) =>
codes.map((code, i) => ({ code, label: i === 0 ? name : `${name} (${code})` })),
).sort((a, b) => a.label.localeCompare(b.label, "en")),
];
export function isDictationLanguage(value: unknown): value is DictationLanguage {
return DICTATION_LANGUAGES.some(({ code }) => code === value);
}
/** Only language varies. Keep the owner's tuned streaming parameters. */
export function deepgramListenUrl(language: DictationLanguage): string {
return "wss://api.deepgram.com/v1/listen" +
"?model=nova-3&encoding=linear16&sample_rate=16000&channels=1" +
"&smart_format=true&punctuate=true&endpointing=300" +
`&language=${encodeURIComponent(language)}`;
}
+3
View File
@@ -38,6 +38,8 @@ export interface ServerEvents {
"activity-changed": Record<string, never>;
/** The session list changed (create, delete, title). */
"chats-changed": Record<string, never>;
/** Web subscription state changed. No account or credential data on the bus. */
"subscriptions-changed": Record<string, never>;
/** A chat's record changed WITHOUT a turn (the silent dismissal write).
* Same notify-then-fetch contract as the turn events: open views of
* the chat refetch and adopt the durable record. */
@@ -61,6 +63,7 @@ export const SERVER_EVENT_TYPES = [
"jobs-changed",
"activity-changed",
"chats-changed",
"subscriptions-changed",
"record-changed",
"compaction-changed",
] as const satisfies readonly ServerEventType[];
+2
View File
@@ -47,6 +47,7 @@ export * from "./input-events.ts";
// the deleted server projection to the client side. Pure, browser-safe.
export * from "./view.ts";
export * from "./tool-parts.ts";
export * from "./answer-presentation.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
@@ -58,6 +59,7 @@ export * from "./fuzzy.ts";
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
// view. Browser-safe — the Wave-4 panel imports these directly.
export * from "./settings.ts";
export * from "./dictation.ts";
// graph-deletion.ts (wave 6): the whole module is PURE but storage-port
// driven — the deletion RUNS server-side only. The shell needs exactly one
// two type-only things from it: the ChatDeleteMode union (the delete
+66
View File
@@ -16,6 +16,9 @@
* continue — run the next turn on the current record (mount
* continuations, job-settle re-syncs: the record ends at an
* answered question with no teacher reaction yet).
* retry — retry a failed model response (issue #110), without new
* input. Failed attempts stay in the record; only the retry's
* temporary agent history omits the trailing failures.
*
* (The plan verdict input died 2026-09-26 with the verdict itself — the
* learner's post-plan reply is a natural text message.)
@@ -45,6 +48,8 @@ export type ChatInput =
| { type: "text"; text: string }
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
| { type: "continue" }
/** The failed-turn retry (issue #110): the banner's Retry button. */
| { type: "retry" }
/** The practice dock's two buttons (owner, 2026-10-04): the learner's
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
* whether it claims an answer is read from the text. One
@@ -170,6 +175,8 @@ export function parseChatInput(input: unknown): ChatInput {
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
case "continue":
return { type: "continue" };
case "retry":
return { type: "retry" };
case "thinking":
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
throw new ChatInputError("thinking input requires problemCallId");
@@ -210,6 +217,10 @@ export function inputAppends(
}
case "continue":
return dismissalConclusion(record);
case "retry":
// The input already landed write-ahead. retryDrops affects only
// the temporary agent history — nothing new appends here.
return [];
case "thinking": {
const active = activeProblemOf(record.messages);
if (!active || active.toolCallId !== input.problemCallId) {
@@ -233,6 +244,61 @@ export function inputAppends(
}
}
/** The failure owning the ephemeral banner. Plan presentation and
* compaction do not resolve an error; substantive later input/output does.
* This is deliberately broader than Retry eligibility below. */
export function failedResponseAtEnd(messages: readonly LhMessage[]): Extract<LhMessage, { role: "assistant" }> | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]!;
if (m.role === "custom" && (m.customType === "plan-block" || m.customType === "compaction")) continue;
return m.role === "assistant" && m.stopReason === "error" ? m : undefined;
}
return undefined;
}
/** Does the record end in a failure with input that can be retried?
* Aborts are deliberate stops; compaction after a failure requires new
* learner input rather than a retry using a changed context. */
export function endsInFailedTurn(messages: readonly LhMessage[]): boolean {
const { ids, before } = failedTail(messages);
return ids.size > 0 && before !== undefined && before.role !== "assistant";
}
/** The ids omitted ONLY from the retry's temporary agent history.
* Failed attempts (including partial text and their ids) stay on disk.
* Pi's provider conversion already skips error/aborted responses, but
* continue() rejects an assistant tail BEFORE conversion. Omitting the
* trailing failures lets that continuation start from the saved input or
* completed tool results without replaying tools or resubmitting input.
* Plan blocks may follow the failure and stay; later compaction blocks
* Retry without changing compaction itself. */
export function retryDrops(messages: readonly LhMessage[]): Set<string> {
const { ids, before } = failedTail(messages);
if (ids.size === 0) {
const failure = failedResponseAtEnd(messages);
if (failure) {
throw new ChatInputError(`${failure.errorMessage ?? "An error occurred."}\n\nThis response cannot be retried after compaction. Send a new message to continue.`);
}
throw new ChatInputError("retry input requires a failed turn to retry");
}
if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from");
return ids;
}
/** Walk back over failed assistants and UI-only plan blocks. A compaction
* is a boundary, not a marker Retry may cross. A failure AFTER a compaction
* can still retry: that compacted context was already its starting state. */
function failedTail(messages: readonly LhMessage[]): { ids: Set<string>; before: LhMessage | undefined } {
const ids = new Set<string>();
let i = messages.length - 1;
for (; i >= 0; i--) {
const m = messages[i]!;
if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id);
else if (!(m.role === "custom" && m.customType === "plan-block")) break;
}
return { ids, before: messages[i] };
}
/** The deferred conclude synthesis (owner ruling, 2026-10-04): a
* problem-dismissal marker waits for the user's next input — the pair
* (synthetic conclude call + ack) rides ahead of it, so the agent reads
+107 -558
View File
@@ -1,572 +1,121 @@
/**
* Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
*
* Current authoring uses `$...$` inline and `$$...$$` on separate lines for
* display (owner, 2026-10-12). This pure transform keeps correctly formed
* old backslash-delimited content readable; dollar math passes through to
* Streamdown's native parser. Escape restoration below recognizes both
* syntaxes while retaining the existing recovery rules.
*
* Rules:
* - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
* `\[...\]` → `$$\n...\n$$` (block form: flow math).
* - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
* - Nothing inside fenced code blocks or inline code spans is touched.
* - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
* an incomplete `\[` completes on a later chunk (the whole text is
* re-transformed each render).
*
* Pure string → string. No markdown parsing — a single linear scan with a
* code-state machine, cheap enough to run in the render path of a memo'd
* component.
*/
/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and
* display-only tool-content recovery. Source arguments are never rewritten. */
import {
canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments,
readContentSpans, recoverDocumentLayout, texRanges,
} from "./content-reader.ts";
const INLINE_OPEN = "\\(";
const INLINE_CLOSE = "\\)";
const DISPLAY_OPEN = "\\[";
const DISPLAY_CLOSE = "\\]";
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
/**
* Is the backslash at `i` itself escaped (i.e. preceded by an odd number of
* backslashes)? If so, the `\(`/`\[` starting there is a literal backslash
* followed by a bracket — not a delimiter.
*/
function isEscaped(text: string, i: number): boolean {
let count = 0;
for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1;
return count % 2 === 1;
}
/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */
function backtickRun(text: string, i: number): number {
let count = 0;
while (text[i + count] === "`") count += 1;
return count;
}
/**
* Try to find the closing delimiter for the opening one at `i` (2 chars,
* already verified unescaped). The close scan stops at inline code spans and
* fence starts (barriers — math never contains them), and returns -1 when the
* delimiter is unbalanced (mid-stream chunk).
*/
function findClose(
text: string,
from: number,
close: string,
): number {
for (let j = from; j < text.length - 1; j++) {
if (text[j] === "`") return -1; // inline code barrier
if (
text[j] === "\\" &&
text[j + 1] === close[1] &&
!isEscaped(text, j)
) {
return j;
}
}
return -1;
}
/** True when `i` sits at the start of a line (position 0 or right after \n). */
function atLineStart(text: string, i: number): boolean {
return i === 0 || text[i - 1] === "\n";
}
/**
* Copy a fenced code block starting at `i` (line start, fence regex matched)
* verbatim to the output. Returns the index after the closing fence line —
* or the end of the text when the fence is unclosed (mid-stream: everything
* after is literal code until the chunk completes).
*/
function copyFencedBlock(text: string, i: number, out: string[]): number {
const open = FENCE_OPEN_RE.exec(text.slice(i));
const marker = open?.[1] ?? "```";
const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
let j = i;
// Copy lines until the matching closing fence.
while (j < text.length) {
const lineEnd = text.indexOf("\n", j);
const next = lineEnd === -1 ? text.length : lineEnd + 1;
const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd);
out.push(text.slice(j, next));
if (j > i && closeRe.test(line)) return next; // closing fence line
j = next;
}
return text.length;
}
/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */
function displayIsBlockPositioned(text: string, i: number): boolean {
for (let j = i - 1; j >= 0; j--) {
const ch = text[j];
if (ch === "\n") return true;
if (ch !== " " && ch !== "\t") return false;
}
return true;
}
/** Is the `\[` at `i` indented (whitespace before it on the line)? */
function displayIsIndented(text: string, i: number): boolean {
let sawWhitespace = false;
for (let j = i - 1; j >= 0; j--) {
const ch = text[j];
if (ch === "\n") return sawWhitespace;
if (ch !== " " && ch !== "\t") return false; // mid-paragraph
sawWhitespace = true;
}
return sawWhitespace;
}
/** Does the closing delimiter end its line (only \n or EOF after it)? */
function closeEndsLine(text: string, afterClose: number): boolean {
return afterClose >= text.length || text[afterClose] === "\n";
}
/**
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
* Everything else passes through byte-for-byte.
*/
/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's
* mathText closer is code-span logic — ANY size-matched `$` run closes,
* `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's
* own auto-render scanner skips escaped dollars, and KaTeX renders `\$`
* fine — the author's intent is unambiguous: an escaped dollar (odd number
* of backslashes before it) never closes the span. Mirrors findDollarClose's
* inline rules otherwise (exact size match, whole-run skip). */
function findInlineDollarCloseEscapeAware(text: string, from: number): number {
for (let j = from; j < text.length;) {
if (text[j] !== "$") {
j += 1;
continue;
}
if (isEscaped(text, j)) {
j += 1;
continue; // `\$` never closes
}
const count = dollarRun(text, j);
if (count === 1) return j;
j += count;
}
return -1;
}
/** Replace `\$` (odd-backslash dollars) inside a math span with the
* dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare
* `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`,
* row break + dollar) are left alone. */
function replaceEscapedDollars(content: string): string {
let replaced = "";
for (let j = 0; j < content.length;) {
if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) {
replaced += "\\text{\\textdollar}";
j += 2;
} else {
replaced += content[j];
j += 1;
}
}
return replaced;
}
export function transformMathDelimiters(markdown: string): string {
const out: string[] = [];
let i = 0;
while (i < markdown.length) {
const ch = markdown[i]!;
// Fenced code blocks: copied verbatim (only detectable at line start).
if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) {
i = copyFencedBlock(markdown, i, out);
continue;
}
// Inline code spans: copied verbatim.
if (ch === "`") {
const run = backtickRun(markdown, i);
let j = i + run;
// Find the matching run (same length, per CommonMark).
while (j < markdown.length) {
const nextRun = backtickRun(markdown, j);
if (nextRun === run) break;
j += Math.max(nextRun, 1);
}
if (j < markdown.length) {
out.push(markdown.slice(i, j + run));
i = j + run;
} else {
// Unclosed run (mid-stream): literal, keep scanning after it.
out.push(markdown.slice(i, i + run));
i += run;
}
continue;
}
// Math delimiters.
if (ch === "\\" && !isEscaped(markdown, i)) {
const next = markdown[i + 1];
if (next === "(" || next === "[") {
const isDisplay = next === "[";
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
const closeAt = findClose(markdown, i + open.length, close);
if (closeAt !== -1) {
const content = markdown.slice(i + open.length, closeAt).trim();
if (content.length > 0) {
if (isDisplay && displayIsIndented(markdown, i)) {
// Indented display math (inside a list item, quote, …):
// an indented $$ flow block half-parses inside list
// continuations (empty math, eaten items), so degrade to
// the inline form — renders inline, stays inside the item.
// mathText cannot span line endings, so collapse them.
out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`);
} else if (isDisplay) {
// Flow math ($$ alone on its lines) renders as display
// KaTeX. Mid-paragraph \[...\] (or trailing text after the
// closer) would half-parse into literal dollars + KaTeX
// errors — pad with blank lines to force a clean block.
// Block-positioned math passes through in place.
const afterClose = closeAt + close.length;
const padBefore =
displayIsBlockPositioned(markdown, i) ? "" : "\n\n";
const padAfter =
closeEndsLine(markdown, afterClose) ? "" : "\n\n";
out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
} else {
// Preserve the legacy reader's two-dollar inline output
// (renders inline, not display).
out.push(`$$${content}$$`);
}
i = closeAt + close.length;
continue;
}
}
// Unbalanced or empty: leave both characters untouched.
}
}
// Escaped dollars inside inline math (owner, 2026-10-12): normalize
// BEFORE tokenization — inside a balanced inline span, `\$` becomes the
// dollar-free `\text{\textdollar}` so the scanner's closer is the one
// the author meant (micromark would otherwise close at the `\$`, error
// out, and cascade). Display runs are copied whole and left untouched —
// a lone `$` can't close `$$`, so `\$` already survives there — and
// copying the run stops its second `$` posing as an inline opener.
// Spans without escaped dollars emit byte-identical text; prose `\$`,
// code, and unbalanced spans are untouched.
if (ch === "$" && !isEscaped(markdown, i)) {
const run = dollarRun(markdown, i);
if (run === 1) {
const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1);
if (closeAt !== -1) {
const content = markdown.slice(i + 1, closeAt);
const replaced = replaceEscapedDollars(content);
if (replaced !== content) {
out.push(`$${replaced}$`);
i = closeAt + 1;
continue;
}
}
} else {
out.push(markdown.slice(i, i + run));
i += run;
continue;
}
}
out.push(ch);
i += 1;
}
return out.join("");
}
/**
* LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix.
*
* When a model emits LaTeX with SINGLE backslashes inside JSON tool-call
* arguments (`\forall`, not the properly escaped `\\forall`), JSON escape
* resolution eats the command's first letter: `\f` → form feed + "orall",
* `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into
* the persisted RAW args and renders as garbage. Inside a MATH SPAN these
* control chars can never be legitimate content, so restoring the two-char
* escape reconstructs the model's intent byte-perfectly. Outside spans the
* same chars might be real prose (pasted tables, formatted text), so only
* balanced math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
* unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
* excluding code and escaped opening dollars. Unbalanced spans pass through.
*
* ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
* restore all six debris chars — real newlines/tabs inside INLINE math
* essentially never occur (options and inline math are single-line by
* nature). Display spans restore five and KEEP real newlines — multi-line
* display math (aligned environments, equation systems) is common, and a
* real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/
* `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting
* legitimate content is strictly worse than failing to repair that rarest
* slice, so display-span n-command sloppiness stays broken by design.
*/
const ESCAPE_DEBRIS: Record<string, string> = {
"\b": "\\b",
"\t": "\\t",
"\n": "\\n",
"\v": "\\v",
"\f": "\\f",
"\r": "\\r",
const escaped = (text: string, i: number) => {
let n = 0; while (i > 0 && text[--i] === "\\") n++;
return n % 2 === 1;
};
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
/** Display spans keep real newlines (multi-line equations) — debris there is
* everything except \n (a lone real \r never occurs in authored text, and
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
export function repairMathForDisplay(text: string): string {
return healOverescapedCommands(restoreMathEscapes(text));
/** Preserve the legacy list-continuation policy: indented display math
* degrades to inline rather than disrupting list structure. Literal code
* indentation is already protected by the shared reader. */
function indented(text: string, i: number): boolean {
const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
return prefix.length > 0 && /^[ \t]+$/.test(prefix);
}
function blockPosition(text: string, i: number): boolean {
return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i));
}
/** micromark closes native single-dollar spans at escaped dollars too.
* Replace an intended literal dollar with equivalent dollar-free TeX before
* tokenization. Code, prose, unbalanced spans and display dollars stay raw. */
function replaceEscapedDollars(content: string): string {
let out = "";
for (let i = 0; i < content.length; i++) {
if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) {
out += "\\text{\\textdollar}"; i++;
} else out += content[i];
}
return out;
}
/** Read old \(...\)/\[...\] syntax without touching literal content or
* reparsing native math as Markdown. Dollar authoring remains native. */
export function transformMathDelimiters(markdown: string): string {
let offset = 0;
return readContentSpans(markdown).map(span => {
const start = offset; offset += span.raw.length;
if (span.kind !== "math") return span.raw;
if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close;
if (span.open !== "\\(" && span.open !== "\\[") return span.raw;
const body = span.body.trim();
if (!body) return span.raw;
if (span.open === "\\(") return `$$${body}$$`;
if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`;
const before = blockPosition(markdown, start) ? "" : "\n\n";
const end = start + span.raw.length;
const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n";
return `${before}$$\n${body}\n$$${after}`;
}).join("");
}
const DEBRIS: Record<string, string> = {
"\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r",
};
/** Recover JSON-swallowed command prefixes only in recognized math, never
* in code or genuine TeX comments. Restore all debris in one pass. Physical
* display newlines and CRLF remain source whitespace. Inline LF recovery is
* limited to complete known n-command names. Under the approved typical-
* model-authoring policy this includes newline+u→nu in inline math only;
* already-correct commands are never decoded into whitespace. */
export function restoreMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
if (!/[\b\t\n\v\f\r]/.test(text)) return text;
return mapMathContent(text, (body, display) => outsideTexComments(body, part => {
const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => {
if (char === "\r" && part[offset + 1] === "\n") return char;
const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0];
return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char;
});
if (display) return restored;
return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) =>
knownTexCommand("n" + suffix) ? "\\n" + suffix : whole);
}));
}
/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
function restoreLegacyMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
const out: string[] = [];
let i = 0;
while (i < text.length) {
const ch = text[i]!;
if (ch === "\\" && !isEscaped(text, i)) {
const next = text[i + 1];
if (next === "(" || next === "[") {
const isDisplay = next === "[";
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
const closeAt = findClose(text, i + open.length, close);
if (closeAt !== -1) {
const inner = text.slice(i + open.length, closeAt);
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close);
i = closeAt + close.length;
continue;
}
}
}
out.push(ch);
i += 1;
}
return out.join("");
}
const commandSymbol = /[,;:!|{}%]/;
function dollarRun(text: string, i: number): number {
let count = 0;
while (text[i + count] === "$") count += 1;
return count;
}
/** Native math fences start at a line's block position; one-line $$…$$
* remains inline. A flow opener may have metadata, but never another $. */
function dollarIsDisplay(text: string, i: number, size: number): boolean {
if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
const end = text.indexOf("\n", i + size);
return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
}
/** Match micromark's delimiter runs: exact size inline, at least the
* opener's size on an otherwise-empty line for display. Inside math,
* backslashes are TeX data, not Markdown escapes for closing dollars. */
function findDollarClose(
text: string,
from: number,
size: number,
isDisplay: boolean,
): { at: number; after: number } | undefined {
if (isDisplay) {
let lineEnd = text.indexOf("\n", from);
while (lineEnd !== -1) {
const start = lineEnd + 1;
lineEnd = text.indexOf("\n", start);
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
if (close && close[2]!.length >= size) {
const at = start + close[1]!.length;
return { at, after: at + close[2]!.length };
}
/** Decode prefixes and row separators according to ORIGINAL TeX context.
* A native pair is literal; an encoded pair supplies a factor, so an encoded
* four-backslash row becomes TWO, never a command prefix. Unknown commands
* without that structural evidence are left alone. */
function recoverCommandRuns(inner: string, display: boolean): string {
const ranges = texRanges(inner);
const recovered = inner.replace(/\\+/g, (run: string, offset: number) => {
const contexts = ranges.filter(r => offset >= r.start && offset < r.end);
if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run;
const next = inner[offset + run.length] ?? "";
const encoded = contexts.filter(r => r.kind === "encoded").at(-1);
const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0];
if (encoded) {
const factor = encoded.factor!;
const rows = run.length % (2 * factor) === 0;
const command = word ? knownTexCommand(word) : commandSymbol.test(next);
if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor);
return run;
}
return undefined;
}
for (let j = from; j < text.length;) {
if (text[j] !== "$") {
j += 1;
continue;
}
const count = dollarRun(text, j);
if (count === size) return { at: j, after: j + count };
j += count;
}
return undefined;
}
/** Dollar recognition only — the same six/five-character repair maps,
* with no over-escaping normalization or mathematical validation. */
function restoreDollarMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text) || !text.includes("$")) return text;
const out: string[] = [];
let i = 0;
while (i < text.length) {
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
i = copyFencedBlock(text, i, out);
continue;
}
if (text[i] === "`") {
const run = backtickRun(text, i);
let j = i + run;
while (j < text.length) {
const nextRun = backtickRun(text, j);
if (nextRun === run) break;
j += Math.max(nextRun, 1);
}
const after = j < text.length ? j + run : i + run;
out.push(text.slice(i, after));
i = after;
continue;
}
// Legacy spans have already received their own inline/display policy.
// Do not reinterpret dollars inside them in this second pass.
if (text[i] === "\\" && !isEscaped(text, i)) {
const next = text[i + 1];
if (next === "(" || next === "[") {
const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
if (closeAt !== -1) {
out.push(text.slice(i, closeAt + 2));
i = closeAt + 2;
continue;
}
}
}
if (text[i] === "$" && !isEscaped(text, i)) {
const size = dollarRun(text, i);
const isDisplay = dollarIsDisplay(text, i, size);
const close = findDollarClose(text, i + size, size, isDisplay);
if (close) {
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
out.push(
text.slice(i, i + size) +
text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
text.slice(close.at, close.after),
);
i = close.after;
continue;
}
out.push(text.slice(i, i + size));
i += size;
continue;
}
out.push(text[i]!);
i += 1;
}
return out.join("");
}
/**
* Over-escaped command heal (owner ruling 2026-10-15) — the doubled-
* backslash fix.
*
* Some models stochastically emit DOUBLED backslashes inside JSON tool-call
* arguments: the saved record holds `\\nabla` (two real backslashes) where
* `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders
* the command name as plain letters — the learner sees "the word nabla, not
* the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro
* doubled per-call, per-field (one probe's question doubled while its
* explanation and options in the SAME call stayed clean — authoring, not
* transport), and deepseek-v4.1-flash once; quadrupling appeared when the
* in-session agent "compensated" for its own doubled first attempt.
*
* THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and
* dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length
* backslash run (2, 4, …) immediately followed by a command start — a letter
* (any command name; bare letters after a row break are never authored
* content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces,
* norm bars) — collapses to a single backslash. Even runs before anything
* else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and
* stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own
* backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched,
* so matrix content cannot be corrupted. Outside spans nothing is touched:
* doubled backslashes in prose or code are not ours to judge. Unbalanced
* spans pass through, exactly like the debris passes.
*/
const COMMAND_START_CLASS = "[A-Za-z,;:!|]";
/** Collapse even backslash runs before a command start; keep odd runs. */
function halveOverescapedCommandRuns(inner: string): string {
return inner.replace(
new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"),
(run: string) => (run.length % 2 === 0 ? "\\" : run),
);
if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run;
if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run;
return next && commandSymbol.test(next) ? "\\" : run;
});
// Structural evidence is primary (parse success cannot prove row
// preservation). As an additional guard, never turn parseable source into
// a parse failure through partial command recovery.
if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner;
return recovered;
}
export function healOverescapedCommands(text: string): string {
if (!text.includes("\\\\")) return text;
const out: string[] = [];
let i = 0;
while (i < text.length) {
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
i = copyFencedBlock(text, i, out);
continue;
}
if (text[i] === "`") {
const run = backtickRun(text, i);
let j = i + run;
while (j < text.length) {
const nextRun = backtickRun(text, j);
if (nextRun === run) break;
j += Math.max(nextRun, 1);
}
const after = j < text.length ? j + run : i + run;
out.push(text.slice(i, after));
i = after;
continue;
}
if (text[i] === "\\" && !isEscaped(text, i)) {
const next = text[i + 1];
if (next === "(" || next === "[") {
const isDisplay = next === "[";
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
const closeAt = findClose(text, i + open.length, close);
if (closeAt !== -1) {
const inner = text.slice(i + open.length, closeAt);
out.push(open + halveOverescapedCommandRuns(inner) + close);
i = closeAt + close.length;
continue;
}
}
}
if (text[i] === "$" && !isEscaped(text, i)) {
const size = dollarRun(text, i);
const isDisplay = dollarIsDisplay(text, i, size);
const close = findDollarClose(text, i + size, size, isDisplay);
if (close) {
out.push(
text.slice(i, i + size) +
halveOverescapedCommandRuns(text.slice(i + size, close.at)) +
text.slice(close.at, close.after),
);
i = close.after;
continue;
}
out.push(text.slice(i, i + size));
i += size;
continue;
}
out.push(text[i]!);
i += 1;
}
return out.join("");
return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text;
}
/** Presentation-only, language-independent recovery. Shape parsing, saved
* identities, grading and model history must use the unprojected source. */
export function repairMathForDisplay(text: string): string {
return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text)));
}
+91 -20
View File
@@ -98,6 +98,55 @@ export function asAgentMessages(messages: readonly LhMessage[]): AgentMessage[]
return messages as unknown as AgentMessage[];
}
// ─── explicit learner-facing output ─────────────────────────────────────────
export const SEND_TO_USER_TOOL = "send_to_user";
/** Read the output tool's message without changing the native call. Also
* accepts partial arguments while streaming; malformed/empty inputs carry
* no prose. This projection is independent of the currently selected model. */
export function sendToUserText(block: unknown): string | undefined {
if (!block || typeof block !== "object") return undefined;
const call = block as { type?: unknown; name?: unknown; arguments?: unknown };
if (call.type !== "toolCall" || call.name !== SEND_TO_USER_TOOL) return undefined;
if (!call.arguments || typeof call.arguments !== "object") return undefined;
const message = (call.arguments as { message?: unknown }).message;
return typeof message === "string" && message.trim() ? message : undefined;
}
/** Learner-facing prose, in content order. Only the explicit output tool
* joins ordinary text here — thinking and all other tool inputs stay out.
* Shared by coverage/extraction and Markdown; native history stays intact. */
export function userFacingTextOf(content: unknown, failedOutputCalls?: ReadonlySet<string>): string[] {
if (typeof content === "string") return content.trim() ? [content] : [];
if (!Array.isArray(content)) return [];
const out: string[] = [];
for (const block of content) {
if (!block || typeof block !== "object") continue;
if (block.type === "toolCall" && failedOutputCalls?.has(block.id)) continue;
const text = block.type === "text" ? block.text : sendToUserText(block);
if (typeof text === "string" && text.trim()) out.push(text);
}
return out;
}
/** Failed output calls remain retry context, not delivered teaching.
* Truncated calls can have valid-looking partial arguments. Failed/aborted
* generations do not execute their calls at all, so may have no result. */
export function failedOutputCallIds(messages: readonly LhMessage[]): ReadonlySet<string> {
const failed = new Set<string>();
for (const message of messages) {
if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
failed.add(message.toolCallId);
} else if (message.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
for (const block of message.content) {
if (block.type === "toolCall" && block.name === SEND_TO_USER_TOOL) failed.add(block.id);
}
}
}
return failed;
}
// ─── id minting ──────────────────────────────────────────────────────────────
/** The app-wide message id shape (same as the UIMessage-era mint). */
@@ -502,9 +551,10 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set<string> {
* - markers flatten to a user message carrying their content (the
* canonical text single-sourced in core/contracts); plan blocks are
* UI-only and filtered.
* - dangling tool calls (an aborted turn's unanswered questions) get the
* synthesized interrupted result — the provider protocol's hard
* invariant: a tool call must be followed by its result.
* - dangling calls from successfully generated assistant messages (for
* example, an unanswered question) get a synthesized interrupted result.
* Error/aborted generations do not: Pi drops those assistant messages,
* so manufacturing their results would leave orphan results on the wire.
*
* Contract (pi-agent-core): never throws — an unknown shape passes
* through untouched rather than killing the loop.
@@ -513,8 +563,9 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// THE COMPACTION CUT (owner round 14, 2026-10-05): the ACTIVE marker
// (the newest — chained compactions rule last) names the first kept
// message; everything before it is replaced by the marker's content
// (header + summary) rendered ONCE, up top. The marker itself is then
// skipped in place — it must never render twice. A cut naming a
// (header + summary) rendered ONCE, up top. All compaction markers
// in the retained tail are then skipped, including older summaries
// whose stored positions still lie after the cut. A cut naming a
// missing id degrades honestly (everything renders — never throw).
const lhMessages = messages as readonly LhMessage[];
const active = activeCompactionOf(lhMessages);
@@ -555,11 +606,10 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// UI-only markers: plan blocks, and problem dismissals (the
// synthesized conclude pair carries the model's face).
if (marker.customType === "plan-block" || marker.customType === "problem-dismissed") continue;
// The ACTIVE compaction marker already rendered as the head —
// never twice. (Stale markers before the cut are unreachable; a
// marker whose cut could not resolve has no head and renders as
// its user text — honest degradation.)
if (marker.customType === "compaction" && head !== null && marker.id === active!.id) continue;
// The head supersedes every compaction marker, not only its own
// stored position. Without a resolved head, preserve the existing
// full-history fallback, including the summary text.
if (marker.customType === "compaction" && head !== null) continue;
out.push({
role: "user",
content: [{ type: "text", text: marker.content }],
@@ -570,11 +620,12 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] {
// Strip the record id: the wire carries the native shape only.
const { id: _id, ...native } = m as LhMessage;
out.push(native as Message);
// Dangling-call synthesis: an assistant message whose toolCalls have
// no results in the remaining tail (abort mid-question) — the
// provider rejects unanswered calls outright.
// Repair unanswered calls from completed generations, not calls in
// failed generations. Pi filters error/aborted assistants itself;
// adding results for their unexecuted calls would leave orphans.
if (role === "assistant") {
const assistant = m as AssistantMessage;
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
const calls = assistant.content.filter((b) => b.type === "toolCall");
if (calls.length === 0) continue;
const answered = resultIdsFrom(messages.slice(i + 1));
@@ -618,15 +669,35 @@ export function danglingQuestionOf(
return null;
}
/** The probe→teach construction seam (chat-handler): a probe_done call in
* the transcript — any state — means the probe is concluded: teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
/** Successful probe_done result MESSAGE ids, in transcript order. A call
* from a failed/aborted generation grants no completion authority. Its
* later result must match both call id and tool name, must not be an
* error, and must not explicitly refuse with details.ok:false. Older
* clean acknowledgments without ok:true still count. No history changes. */
export function probeDoneResultIds(messages: readonly LhMessage[]): Set<string> {
const calls = new Set<string>();
const results = new Set<string>();
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const assistant = m as AssistantMessage;
if (assistant.content.some((b) => b.type === "toolCall" && b.name === "probe_done")) return true;
if (m.role === "assistant") {
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
for (const block of m.content) {
if (block.type === "toolCall" && block.name === "probe_done") calls.add(block.id);
}
} else if (
m.role === "toolResult" && m.toolName === "probe_done" &&
calls.has(m.toolCallId) && !m.isError &&
(m.details as { ok?: unknown } | null | undefined)?.ok !== false
) {
results.add(m.id);
}
}
return false;
return results;
}
/** The probe→teach construction seam (chat-handler and the tool guard):
* only a concluded call plus a matching successful result means teach. */
export function hasProbeDone(messages: readonly LhMessage[]): boolean {
return probeDoneResultIds(messages).size > 0;
}
/** Every toolResult id in the record — answer-event validation (the call
+8 -11
View File
@@ -33,7 +33,7 @@
* Both are strictly sequential; the two derivations agree on every settled
* record (a call's part projects into the same view boundary).
*
* Pure: no imports, no runtime deps — safe on server, client, and tests.
* Pure: only browser-safe core helpers — safe on server, client, and tests.
*/
/** The three phases of a session, as the picker / header / dividers see them. */
@@ -80,7 +80,10 @@ function advance(
// plan → teach only via the first nodeId-tagged quiz CALL (the call,
// not its answer — an errored-and-retried call still proves teaching
// began, and the retry lands in the same group anyway).
if (phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available") {
if (
phase === "probe" && part.type === "tool-probe_done" && part.state === "output-available" &&
(part.output as { ok?: unknown } | null | undefined)?.ok !== false
) {
phase = "plan";
}
// plan → teach via the first nodeId-tagged quizzing CALL — quiz OR
@@ -153,7 +156,7 @@ export function phaseTimeline(messages: readonly PhaseMessage[]): Phase3[] {
// ─── the native fold (Wave D: record-side phase over LhMessage[]) ───────────
import type { LhMessage } from "./native-messages.ts";
import { probeDoneResultIds, type LhMessage } from "./native-messages.ts";
/** The phase at `upToIndex` (inclusive; default: the last message) of a
* NATIVE record. probe→plan advances at the probe_done call's clean
@@ -163,18 +166,12 @@ import type { LhMessage } from "./native-messages.ts";
export function phaseAt3Lh(messages: readonly LhMessage[], upToIndex?: number): Phase3 {
let phase: Phase3 = "probe";
const last = Math.min(upToIndex ?? messages.length - 1, messages.length - 1);
const concluded = probeDoneResultIds(messages.slice(0, last + 1));
for (let i = 0; i <= last; i++) {
const m = messages[i]!;
const role = (m as { role?: string }).role;
if (role === "toolResult") {
const result = m as { toolName?: string; isError?: boolean };
if (
phase === "probe" &&
result.toolName === "probe_done" &&
!result.isError
) {
phase = "plan";
}
if (phase === "probe" && concluded.has(m.id)) phase = "plan";
} else if (role === "assistant") {
const content = (m as { content?: ReadonlyArray<{ type?: string; name?: string; arguments?: unknown }> }).content ?? [];
if (phase === "plan") {
+2
View File
@@ -136,6 +136,8 @@ export interface CreateJobInit {
toolCallId: string;
/** First activity line (defaults to "starting"). */
activity?: string;
/** Saved on the job before its worker is spawned. */
input?: unknown;
}
/** Mergeable fields — identity (id, kind, chatId, toolCallId, createdAt) is immutable. */
+15
View File
@@ -511,6 +511,21 @@ export interface DockSelection {
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
/** In-memory submission snapshot: survives the question form's optimistic
* unmount, never written to storage. Indices use the call's stable shuffle. */
export interface QuestionFormDraft {
selection: DockSelection;
thinking: string;
other: string;
text: string;
confidenceIndex: number;
}
export interface QuestionDraft {
form?: QuestionFormDraft;
readingThinking?: string;
}
/**
* Toggling a listed option. Single-select: the option becomes THE answer and
* displaces "Other". Multi-select: the option toggles alongside its siblings
+8 -14
View File
@@ -22,22 +22,15 @@
import type { LhMessage } from "./native-messages.ts";
import {
failedOutputCallIds,
PRACTICE_CONCLUDE_TOOL,
PRACTICE_PROBLEM_TOOL,
problemInputForConclude,
SEND_TO_USER_TOOL,
userFacingTextOf,
} from "./native-messages.ts";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
/** The user-facing text blocks of a native message (string or text parts). */
function textOf(content: unknown): string[] {
if (typeof content === "string") return content.trim() ? [content] : [];
if (!Array.isArray(content)) return [];
return content
.filter((c): c is { type: "text"; text: string } => (c as { type?: string }).type === "text")
.map((c) => c.text)
.filter((t) => t.trim().length > 0);
}
/** The practice join context (owner-approved, 2026-10-06 export round):
* practice tool RESULTS are acks — the statement, outcome, and verdict
* all live in the assistant's call arguments, and the model solution
@@ -48,6 +41,7 @@ export interface MarkdownContext {
messages: ReadonlyArray<LhMessage>;
/** toolCallId → the call part's name + arguments. */
calls: Map<string, { name: string; args: Record<string, unknown> | undefined }>;
failedOutputCalls?: ReadonlySet<string>;
}
function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
@@ -66,7 +60,7 @@ function markdownContext(messages: ReadonlyArray<LhMessage>): MarkdownContext {
}
}
}
return { messages, calls };
return { messages, calls, failedOutputCalls: failedOutputCallIds(messages) };
}
/** solvedWithHints → "Solved with hints" — the outcome enum reads as prose. */
@@ -172,16 +166,16 @@ export function messageMarkdown(
const parts: string[] = [];
if (role === "user") {
for (const text of textOf((message as { content: unknown }).content)) {
for (const text of userFacingTextOf((message as { content: unknown }).content)) {
parts.push(`### Learner${anchor}\n\n${text.trim()}`);
}
} else if (role === "assistant") {
for (const text of textOf((message as { content: unknown }).content)) {
for (const text of userFacingTextOf((message as { content: unknown }).content, ctx?.failedOutputCalls ?? failedOutputCallIds([message]))) {
parts.push(`### Teacher${anchor}\n\n${text.trim()}`);
}
} else if (role === "toolResult") {
const result = message as ToolResultMessage;
if (result.isError) return null; // retry context, not learning evidence
if (result.isError || result.toolName === SEND_TO_USER_TOOL) return null; // retry context or output ack, not learning evidence
const { heading, body } = toolResultBody(result, ctx);
parts.push(`### ${heading}${anchor}\n\n${body.join("\n\n")}`);
}
+5
View File
@@ -35,6 +35,7 @@ import {
type BackgroundModelRole,
type FactorySettings,
type GeneralSettings,
type DictationSettings,
type CompactionSettings,
type GraphSettings,
type ModelSlot,
@@ -69,6 +70,7 @@ export type EffectiveAppearance = FactorySettings["appearance"];
/** The concrete view: every knob resolved, no absent fields. */
export interface EffectiveSettings {
general: Required<GeneralSettings>;
dictation: Required<DictationSettings>;
agent: {
models: Record<BackgroundModelRole, EffectiveModelSpec | undefined>;
/** Delta map as stored — consumers resolve absent key → enabled. */
@@ -176,6 +178,9 @@ export function resolveSettings(record: UserSettings | null): EffectiveSettings
general: {
autoUpdate: record?.general?.autoUpdate ?? FACTORY_SETTINGS.general.autoUpdate,
},
dictation: {
language: record?.dictation?.language ?? FACTORY_SETTINGS.dictation.language,
},
agent: {
models,
tools: agent.tools ?? {},
+17 -1
View File
@@ -32,6 +32,7 @@ import { AUTH_PROVIDER_KEYS } from "./auth-keys.ts";
import type { ModelRole } from "../../config.ts";
import type { ChatSettings } from "./contracts/chat.ts";
import type { SearchEngineId } from "./search.ts";
import { isDictationLanguage, type DictationLanguage } from "./dictation.ts";
/** The schema version this code understands. Bump ONLY for breaking shape
* changes (a field renamed/meaning changed); pure additions never bump. */
@@ -231,10 +232,15 @@ export const ROLE_SLOT: Readonly<Record<ModelRole, ModelSlotName>> = {
/** General settings — app-level behavior. Version DISPLAY and plans login
* live outside the record (package.json / auth.json) by owner decision. */
export interface GeneralSettings {
/** Auto-update the app (default ON). Mechanism ships in a later wave. */
/** Automatic startup/24h update checks (default ON). Manual checks bypass this. */
autoUpdate?: boolean;
}
/** One app-wide dictation preference, shared by every composer. */
export interface DictationSettings {
language?: DictationLanguage;
}
/** Agent settings — everything about the non-conversation machinery. */
export interface AgentSettings {
/** Background-role model/thinking overrides (absent role = config pin). */
@@ -361,6 +367,7 @@ export interface CompactionSettings {
export interface UserSettings {
version: number;
general?: GeneralSettings;
dictation?: DictationSettings;
agent?: AgentSettings;
/** The strong/cheap/vision model slots (model slots wave, 2026-09-20). */
models?: ModelsSettings;
@@ -378,6 +385,7 @@ export interface UserSettings {
export interface FactorySettings {
version: number;
general: Required<GeneralSettings>;
dictation: Required<DictationSettings>;
appearance: {
mode: "dark" | "light";
background: string | null;
@@ -397,6 +405,7 @@ export interface FactorySettings {
export const FACTORY_SETTINGS: FactorySettings = {
version: CURRENT_SETTINGS_VERSION,
general: { autoUpdate: true },
dictation: { language: "en" },
appearance: {
mode: "dark",
background: null, // the dark default (#141414) lives in the mode's derivation curve
@@ -615,6 +624,13 @@ export function sanitizeUserSettings(raw: unknown): UserSettings | null {
if (r.general !== null && typeof r.general === "object" && !Array.isArray(r.general)) {
out.general = sanitizeGeneral(r.general as Record<string, unknown>);
}
if (r.dictation !== undefined) {
const raw = r.dictation;
out.dictation = raw !== null && typeof raw === "object" && !Array.isArray(raw) &&
isDictationLanguage((raw as Record<string, unknown>).language)
? { language: (raw as DictationSettings).language }
: {};
}
if (r.agent !== null && typeof r.agent === "object" && !Array.isArray(r.agent)) {
out.agent = sanitizeAgent(r.agent as Record<string, unknown>);
}
+6 -26
View File
@@ -30,7 +30,6 @@
import { Value } from "typebox/value";
import type { TSchema } from "typebox";
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
import { repairMathForDisplay } from "./math-delims.ts";
import { healEncodedArguments } from "./argument-heal.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -67,28 +66,9 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
* widens to "") renders as a quiet fallback, not a broken quiz.
*/
/** LaTeX-escape restoration, deep over every string field (the JSON-debris
* fix, owner ruling 2026-09-26): records persist RAW call args, so
* single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
* \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
* display path. This is the ONE seam every rendered question passes
* through — new turns and already-poisoned records alike heal here.
* healOverescapedCommands (owner ruling 2026-10-15) rides the same walk:
* the same models also stochastically DOUBLE command backslashes
* (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break
* plus "nabla" as letters), so even runs before a command start inside
* balanced math spans collapse to one backslash. */
function restoreMathEscapesDeep(value: unknown): unknown {
if (typeof value === "string") return repairMathForDisplay(value);
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
if (typeof value === "object" && value !== null) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v);
return out;
}
return value;
}
/** Shape parsing never repairs authored text. The dock builds its answer
* data from this source copy; recovery belongs exclusively to presentation
* (ToolContentResponse), never option identities, grading, or snapshots. */
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
// OPTIONAL `type` — new teach-phase records carry predict|check and the
@@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
// wobble must heal HERE too or a posed quiz renders invisible (the
// tool-parts law). healEncodedArguments is the same pure pass the
// boundary's withArgumentHeal runs.
const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema);
const healedInput = healEncodedArguments(input, quizDisplayInputSchema);
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, healedInput);
if (!cleaned) return undefined;
if (quizInputProblems(cleaned).length > 0) return undefined;
@@ -114,7 +94,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
* keying, so they parse to undefined exactly like the zod seam did).
*/
export function parseAskInput(input: unknown): AskToolInput | undefined {
const cleaned = boundaryParse<AskToolInput>(askInputSchema, restoreMathEscapesDeep(input));
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
if (!cleaned) return undefined;
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
return cleaned;
@@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
* variants share the same fields; the teacher's nodeId is an unknown
* key Value.Clean strips, same as the quiz seam. */
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, restoreMathEscapesDeep(input));
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, input);
if (!cleaned) return undefined;
if (explainInputProblems(cleaned).length > 0) return undefined;
return cleaned;
+27 -1
View File
@@ -34,7 +34,7 @@
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
import type { LhMarkerMessage, LhMessage } from "./native-messages.ts";
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude } from "./native-messages.ts";
import { modelSolutionForConclude, PRACTICE_CONCLUDE_TOOL, problemInputForConclude, SEND_TO_USER_TOOL, sendToUserText } from "./native-messages.ts";
import { activeCompactionOf, type CompactionDetails } from "./compaction.ts";
import { QUESTION_TOOLS } from "./pending.ts";
import type { SubagentArtifactData, SubagentResultMeta } from "./contracts/jobs.ts";
@@ -124,6 +124,14 @@ function lastSuccessfulPlanInit(parts: LhViewPart[]): number {
* view messages. Pure: the same input always projects to the same list. */
export function projectView(messages: readonly LhMessage[], live?: LiveTurnState): LhViewMessage[] {
const out: LhViewMessage[] = [];
// Output calls become text rather than tool parts, so read settled
// failures up front: a failed call must retain the normal error path.
const outputErrors = new Map<string, ToolResultMessage>();
for (const message of messages) {
if (message.role === "toolResult" && message.toolName === SEND_TO_USER_TOOL && message.isError) {
outputErrors.set(message.toolCallId, message);
}
}
const lastAssistant = (): LhViewMessage | undefined => {
const last = out[out.length - 1];
@@ -218,6 +226,24 @@ export function projectView(messages: readonly LhMessage[], live?: LiveTurnState
reasoning.push({ type: "reasoning", id: `${m.id}-r${reasoningN++}`, text: block.thinking, state: "done" });
} else if (block.type === "toolCall") {
const part = toolPartOf(block, live, streamingMessage);
if (block.name === SEND_TO_USER_TOOL) {
// Failed/aborted generations never execute their calls;
// the provider error/abort UI already owns that outcome.
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue;
const error = outputErrors.get(block.id);
if (error) applyResult(part, error);
if (part.state === "output-error") {
rest.push(part);
} else {
const text = sendToUserText(block);
if (text !== undefined) {
rest.push({ type: "text", id: `${m.id}-t${textN++}`, text, state: "done" });
}
}
// No live tool card or acknowledgement: this channel is
// ordinary prose, including while its arguments stream.
continue;
}
// The conclusion's reveal join (owner round, 2026-10-05): the
// hero of a conclude card is the modelSolution committed at POSE
// time — a record fold, enriched here so the card renders pure.
+84 -22
View File
@@ -17,7 +17,7 @@ import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
import type { ModelRole } from "../../config.ts";
import {
deliverJobResult,
noteSteerVisible,
noteSteersDurable,
reconcileUserSteers,
registerLiveAgent,
restoreUncommittedAutomatic,
@@ -42,8 +42,8 @@ import {
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
import { beginTurn, endTurn } from "./turn-liveness.ts";
import { applyParkedDismissal } from "./dismissal-park.ts";
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
import { persistTurn, triggerExtraction } from "./turn-service.ts";
import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
import { persistTurn, triggerExtraction, TurnPersistenceError } 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";
@@ -55,10 +55,11 @@ import {
ChatInputError,
inputAppends,
parseChatInput,
retryDrops,
type TurnInput,
} from "../core/input-events.ts";
import { steerDelivered } from "../core/contracts.ts";
import type {CallUsage, SteerMessage} from "../core/contracts.ts";
import type {CallUsage} from "../core/contracts.ts";
/**
* One chat turn over the server-owned native record. Framework-free (plain
@@ -79,6 +80,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
* for write-ahead persistence, turn liveness, and the turn services.
*/
function settlementErrorText(error: unknown): string {
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
}
const jobRunner = createJobRunner({
chatStore: stores.chatStore,
jobStore: stores.jobStore,
@@ -182,6 +187,17 @@ export async function handleChatInput(
return Response.json({ error: "Unknown chat" }, { status: 404 });
}
// Refuse an ineligible Retry before catalog refresh, compaction, or any
// turn/write effects. Revalidate after the awaited gates below too.
if (input.type === "retry") {
try {
retryDrops(record.messages);
} catch (err) {
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
throw err;
}
}
// THE UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the
// CONVERSATION chain — the chat's own model override over the strong
// slot — and there is no factory floor; when both are empty the turn
@@ -255,7 +271,20 @@ export async function handleChatInput(
chatId,
);
const compactedRecord = stores.chatStore.getChat(chatId);
if (compactedRecord) record = compactedRecord;
if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 });
record = compactedRecord;
// Recompute Retry eligibility on current truth, still before beginTurn.
// A compaction marker or another writer may have landed during awaits.
// These omissions affect ONLY the agent context, never the saved record.
let retryOmissions = new Set<string>();
if (input.type === "retry") {
try {
retryOmissions = retryDrops(record.messages);
} catch (err) {
if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
throw err;
}
}
// The input's native appends (pure; the honest 400 for an answer with no
// pending question call). Computed BEFORE beginTurn so a refused input
@@ -289,7 +318,11 @@ export async function handleChatInput(
// the composer gates), so adjacency holds by construction.
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
const merged = [...record.messages, ...appends];
stores.chatStore.saveMessages(chatId, merged);
try { stores.chatStore.saveMessages(chatId, merged); }
catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
return new Response("Your input could not be saved. Please try again.", { status: 500 });
}
emit("record-changed", { chatId });
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
}
@@ -309,18 +342,26 @@ 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];
// Retry preserves every persisted attempt. Pi needs a non-assistant
// tail to start continue(), so its temporary history omits the trailing
// failures only. Count that SAME temporary prefix at settlement, or the
// retained failures would make persistTurn skip genuinely new output.
const agentMessages = input.type === "retry" ? merged.filter((m) => !retryOmissions.has(m.id)) : merged;
const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers";
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
let persistBaseCount = stagedAutomatic ? record.messages.length : agentMessages.length;
try {
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 stagedAutomatic ? [] : appends) {
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
}
} catch (err) {
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
endTurn(chatId);
return new Response("Your input could not be saved. Please try again.", { status: 500 });
}
// Visibility bookkeeping is not the canonical save. Its failure must
// neither misreport a saved input as lost nor prevent the turn running.
try {
if (!stagedAutomatic) noteSteersDurable(chatId, merged);
} catch (err) {
console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err);
}
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
@@ -346,7 +387,7 @@ export async function handleChatInput(
// the session is still probing: build the PROBER (quiz/ask + probe_done).
// Both share this whole composition (write-ahead persist, liveness,
// steers, usage, extraction) — only the agent differs.
const teaching = hasProbeDone(merged);
const teaching = hasProbeDone(agentMessages);
// BYOK (owner, 2026-09-22): the conversation chain was resolved by the
// turn gate above (chat.model override → strong slot → refused) — the
// engine build is pure from here; thinking levels clamp natively.
@@ -365,19 +406,23 @@ export async function handleChatInput(
// sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the
// user: quiz/ask/fs/plan/probe_done are always-on).
if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({
initialMessages: asAgentMessages(merged),
initialMessages: asAgentMessages(agentMessages),
staged: stagedAutomatic ? asAgentMessages(appends) : undefined,
foregroundOwed: !callPurpose.background,
purpose: callPurpose,
scopeCurrent: (scope) => connectionScopeCurrent(conversation.provider, scope),
commitStaged: () => { stores.chatStore.saveMessages(chatId, merged); persistBaseCount = merged.length; },
commitStaged: () => {
stores.chatStore.saveMessages(chatId, merged);
persistBaseCount = merged.length;
noteSteersDurable(chatId, merged);
},
restoreAutomatic: (automaticIds, retainedIds) => restoreUncommittedAutomatic(chatId, constructed, automaticIds, retainedIds),
});
const agentOpts: AgentBuildOptions = {
engine,
callPurpose,
requestCycle,
messages: asAgentMessages(merged),
messages: asAgentMessages(agentMessages),
onUsage: collectUsage,
toolToggles: effective.agent.tools,
// Debug (owner, 2026-10-04): teacher/prober raw provider events under
@@ -468,11 +513,12 @@ export async function handleChatInput(
// run's first LLM call (the native replacement for continuation
// coalescing). Settlement still cannot beat the flow below: it needs
// a streamed response, which never happens inside the constructor.
registerLiveAgent(chatId, constructed);
registerLiveAgent(chatId, constructed, input.type !== "steers" && input.type !== "continue",
input.type === "steers" ? new Set(appends.map((message) => message.id)) : undefined);
// Debug recorder run marker (owner, 2026-10-04): correlates the
// JSONL event stream with the record's turns; no-op unless
// LH_PROVIDER_EVENTS=1.
providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`);
providerEventNote(chatId, "turn", `run start (base: ${agentMessages.length} messages)`);
const handle = runPiTurn({
agent: constructed,
requestCycle,
@@ -496,10 +542,26 @@ export async function handleChatInput(
console.error(`[chat ${chatId}] turn error:`, error);
return "An error occurred.";
},
onSettleError: settlementErrorText,
onSettleFailure: (error, reportedErrorText) => {
// POST only acknowledges acceptance; the submitter may attach
// after this turn has already failed and left the registry.
rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error));
endTurn(chatId);
unregisterTurn(chatId);
unregisterLiveAgent(chatId);
// The canonical save may have succeeded before later bookkeeping
// threw. Reconcile against actual durability, never replay input
// merely because usage/extraction/other settlement work failed.
const persisted = stores.chatStore.getChat(chatId)?.messages ?? [];
reconcileUserSteers(chatId, persisted);
settleSteers(chatId, persisted, true, constructed);
},
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.
// Wave B: append only messages past the temporary agent prefix.
// Retry's saved record still includes its earlier failed attempts.
const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted);
noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped);
// 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.
@@ -545,7 +607,7 @@ export async function handleChatInput(
// resolve; stranded markers return to pending), then let
// the pump take any stragglers down the idle wake.
unregisterLiveAgent(chatId);
settleSteers(chatId, stamped);
settleSteers(chatId, stamped, false, constructed);
// Composer steers (2026-10-03): delivered ids dropped, stranded
// ones stay parked for the chat's next run (the registry is the
// stranding safety net — the agent's undrained copy died above).
+27 -6
View File
@@ -1,10 +1,13 @@
import { abortExtraction, extractionStatuses } from "../agent/subagents/extraction.ts";
import { isJobLive } from "../agent/subagents/aborts.ts";
import { coverageOf } from "../core/coverage.ts";
import { deleteChatTraces, type ChatDeleteMode, type ChatDeletionStats } from "../core/graph-deletion.ts";
import { titleFromPrompt } from "../core/contracts.ts";
import { graphRevision } from "./graph.ts";
import { stores } from "./stores.ts";
import { isTurnRunning } from "./turn-liveness.ts";
import { clearTurnFailure } from "./turn-runs.ts";
import { discardChatDeliveries } from "./jobs.ts";
/**
* Server-side chat entries for the transport layer (app/api/chats*,
@@ -65,10 +68,7 @@ export type ChatDeleteOutcome =
* the wind-down finds the chat gone at its merge and aborts with
* nothing merged.
*/
export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise<ChatDeleteOutcome> {
if (!stores.chatStore.getChat(chatId)) {
return { ok: false, status: 404, code: "not_found", error: "No such session" };
}
function runningWorkBlocker(chatId: string): Extract<ChatDeleteOutcome, { status: 409 }> | null {
if (isTurnRunning(chatId)) {
return {
ok: false,
@@ -78,7 +78,11 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
jobIds: [],
};
}
const runningJobs = stores.jobStore.listJobs(chatId).filter((job) => job.status === "running");
// Legacy records kept completed workers running until delivery. A saved
// result without a live loop is pending session data, not computing work;
// deletion must not depend on a prior recovery poll to recognize it.
const runningJobs = stores.jobStore.listJobs(chatId).filter((job) =>
job.status === "running" && (!job.result || isJobLive(job.id)));
if (runningJobs.length > 0) {
return {
ok: false,
@@ -88,6 +92,15 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
jobIds: runningJobs.map((job) => job.id),
};
}
return null;
}
export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): Promise<ChatDeleteOutcome> {
if (!stores.chatStore.getChat(chatId)) {
return { ok: false, status: 404, code: "not_found", error: "No such session" };
}
const blocker = runningWorkBlocker(chatId);
if (blocker) return blocker;
if (extractionStatuses().some((run) => run.chatId === chatId)) {
abortExtraction(chatId); // true cancellation — spend stops, nothing merges
const deadline = Date.now() + WIND_DOWN_TIMEOUT_MS;
@@ -104,11 +117,19 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
}
}
// A result wake or a worker may have started during extraction wind-down.
// Recheck immediately before the synchronous purge, with no await gap.
const lateBlocker = runningWorkBlocker(chatId);
if (lateBlocker) return lateBlocker;
// THE SYNC BLOCK — no awaits below (see the doc comment). Order:
// ledgers → jobs → the chat record.
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
stores.chatStore.deleteChat(chatId);
if (stores.chatStore.deleteChat(chatId)) {
discardChatDeliveries(chatId);
clearTurnFailure(chatId);
}
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
}
+15 -2
View File
@@ -69,7 +69,7 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const { text } = await runSubagent([], {
const { text, newMessages } = await runSubagent([], {
// The engine override is always present (the chat's own conversation
// engine — owner ruling), so the role label never resolves an
// engine; "researcher" is the least-wrong existing ModelRole label
@@ -83,6 +83,12 @@ async function subagentSummarizer(prompt: string, engine: PiEngine): Promise<{ s
usage = u;
},
});
// A provider-declared incomplete response is not a usable checkpoint,
// even when it contains nonempty text. Match Pi's compaction failure
// semantics: leave the existing history and prior summary untouched.
if (newMessages.some((message) => message.role === "assistant" && message.stopReason === "length")) {
throw new Error("Compaction summary hit the output limit and is incomplete");
}
return { summary: text, usage };
}
@@ -201,7 +207,14 @@ async function compact(deps: CompactionDeps, chatId: string): Promise<void> {
spanStart = prevKept;
priorSummary = previous.content;
}
const spanText = serializeForSummary(messages.slice(spanStart, cut));
const span = messages.slice(spanStart, cut);
// The separately supplied prior summary replaces all summary markers
// in this span. Without a resolved prior boundary, retain the existing
// legacy fallback rather than silently discarding summary-only context.
const summaryMessages = priorSummary
? span.filter((m) => m.role !== "custom" || m.customType !== "compaction")
: span;
const spanText = serializeForSummary(summaryMessages);
const fullSpan = priorSummary ? `[SYSTEM NOTE — prior compaction summary, still authoritative where not superseded]\n${priorSummary}\n\n${spanText}` : spanText;
const prompt = buildCompactionPrompt({
+1
View File
@@ -43,4 +43,5 @@ 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 { subscriptionControlsResponse, subscriptionBrowserResponse } from "./web-subscription-http.ts";
export { sessionCost } from "./pricing.ts";
+185 -163
View File
@@ -1,7 +1,7 @@
/**
* THE WAKE PIPELINE (contracts.ts "THE ASYNC PROTOCOL" §4) — server-side
* steer delivery for finished sub-agent jobs. Wave C (2026-09-26):
* delivery rides pi's OWN follow-up queue when a run is live, and the
* delivery rides pi's OWN steering queue when a run is live, and the
* idle wake otherwise. One registry, two vehicles.
*
* deliverJobResult(steer) — the runner calls this on job completion.
@@ -40,15 +40,17 @@
*
* settleSteers(chatId, persisted) — the handler calls this at turn
* settlement (after unregisterLiveAgent): entries the run DELIVERED
* resolve + flip their jobs; entries that were handed to the dying
* resolve + acknowledge delivery; entries handed to the dying
* run but never injected (stopper exit, abort, a missed final poll)
* return to pending and the pump restarts — no stranding, no loss.
*
* Crash recovery (recoverStaleJobs): a process restart can orphan jobs —
* "running" with a result (the wake never fired) or without one (the
* sub-agent loop itself died). The sweep re-delivers finished results
* (idempotently) and re-spawns unfinished loops, both via injected
* seams. It runs lazily off the /api/jobs poll, throttled.
* Worker status ends at computation completion; JobRecord.delivery owns
* the pending result or abort notice independently. Registry entries
* survive in-flight wakes, so duplicate submissions share one promise.
* Failed starts stay pending without timers or worker errors. Recovery
* restores known pending deliveries and re-spawns unfinished workers;
* registration also restores pending delivery before the first poll.
* Legacy terminal jobs without delivery metadata are never replayed.
*
* All mutable state lives on globalThis (the turn-liveness lesson: Next dev
* gives each route entry its own module instances — a module-level Map would
@@ -83,6 +85,14 @@ export type JobRespawn = (jobId: string) => void;
interface QueuedSteer {
steer: SteerMessage;
promise: Promise<void>;
/** Wake cargo remains owned here, but must not steer into its own run. */
waking?: boolean;
/** The constructed vehicle, once startup claims a turn. Until then only
* the pump owns waking: an unrelated foreground settlement cannot free it. */
wakeAgent?: Agent;
/** A failed start waits for a learner run or process restart, not a timer. */
deferred?: boolean;
resolve: () => void;
reject: (reason: Error) => void;
/** The live agent this marker was steered into (agent.steer) — set by
@@ -166,18 +176,37 @@ function jobIdOf(steer: SteerMessage): string | null {
return typeof details?.jobId === "string" ? details.jobId : null;
}
/** Dock bookkeeping: the marker reached the learner's view — flip its job
* NOW instead of waiting for the delivering turn to settle. Called from
* BOTH visibility seams: the marker's message_end wire event (live steer
* injection — the card is on screen while the teacher keeps teaching) and
* the write-ahead persist in handleChatInput (the wake's input appends —
* the card is durable in the record). The registry's delivery promise
* still settles via settleSteers / the runner-200 path; finalizeJob is
* idempotent, so the later flip is a no-op. Owner ruling (2026-09-26): a
* delivered subagent leaves the background dock the moment its result is
* visible, not when the teacher's turn ends. */
export function noteSteerVisible(steer: SteerMessage): void {
finalizeSteer(steer);
/** Call only after canonical conversation persistence. Streaming visibility
* is not durable acceptance. The saved marker wins over delivery-bookkeeping
* failures; acknowledging delivery never changes the worker's outcome. */
export function noteSteersDurable(chatId: string, persisted: readonly LhMessage[]): void {
for (const message of persisted) {
if (message.role === "custom") finalizeSteer(message);
}
for (const entry of [...(state().queues.get(chatId) ?? [])]) {
if (persisted.some((message) => message.id === entry.steer.id)) completeEntry(chatId, entry);
}
}
function removeEntry(chatId: string, entry: QueuedSteer): void {
const remaining = (state().queues.get(chatId) ?? []).filter((candidate) => candidate !== entry);
if (remaining.length > 0) state().queues.set(chatId, remaining);
else state().queues.delete(chatId);
}
function completeEntry(chatId: string, entry: QueuedSteer): void {
finalizeSteer(entry.steer);
removeEntry(chatId, entry);
entry.resolve();
}
/** Deletion discards the session's obligations, never wakes a deleted chat. */
export function discardChatDeliveries(chatId: string): void {
const s = state();
const queue = s.queues.get(chatId) ?? [];
s.queues.delete(chatId);
s.userSteers.delete(chatId);
for (const entry of queue) entry.resolve();
}
/** Finalize a delivered steer's job bookkeeping — job steers only. */
@@ -186,24 +215,30 @@ function finalizeSteer(steer: SteerMessage): void {
if (jobId) finalizeJob(jobId);
}
/** Fail a steer's job on unrecoverable delivery failure — job steers only. */
function failSteer(steer: SteerMessage, err: Error): void {
const jobId = jobIdOf(steer);
if (jobId) failJob(jobId, err);
/** Queue one logical delivery per ID, including in-flight wake cargo.
* Job promises resolve on durable marker acceptance (or session deletion);
* failed starts stay pending. Non-job probe handoffs keep their existing
* wake-acceptance/error semantics. Workers never await this promise. */
export function deliverJobResult(steer: SteerMessage): Promise<void> {
const chatId = chatIdOf(steer);
const entry = queueSteer(chatId, steer);
void pump(chatId);
return entry.promise;
}
/** Queue ANY steer (result, abort, probe handoff) through the pipeline.
* Resolves when the wake turn has completed; rejects when delivery fails
* unrecoverably. Never rejects for queueing itself. */
export function deliverJobResult(steer: SteerMessage): Promise<void> {
return new Promise<void>((resolve, reject) => {
const s = state();
const chatId = chatIdOf(steer);
const queue = s.queues.get(chatId) ?? [];
queue.push({ steer, resolve, reject });
s.queues.set(chatId, queue);
void pump(chatId);
});
function queueSteer(chatId: string, steer: SteerMessage): QueuedSteer {
const queue = state().queues.get(chatId) ?? [];
const existing = queue.find((entry) => entry.steer.id === steer.id);
if (existing) return existing;
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej; });
// Recovery/registration may own an entry with no waiting caller.
void promise.catch(() => undefined);
const entry: QueuedSteer = { steer, promise, resolve, reject };
queue.push(entry);
state().queues.set(chatId, queue);
return entry;
}
/** Hand every eligible pending marker to a live agent's steering queue
@@ -227,7 +262,7 @@ function handOffEligible(chatId: string, agent: Agent): void {
// 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") {
if (!entry.waking && !entry.deferred && entry.handedTo !== agent && entry.steer.customType !== "probe-handoff") {
agent.steer(entry.steer);
entry.handedTo = agent;
}
@@ -254,8 +289,13 @@ function handOffEligible(chatId: string, agent: Agent): void {
* markers are steered into the run right here. The settle seam cannot
* beat the registration: settlement needs a streamed response, which
* never happens inside the constructor call. */
export function registerLiveAgent(chatId: string, agent: Agent): void {
export function registerLiveAgent(chatId: string, agent: Agent, allowDeferred = true, wakeSteerIds?: ReadonlySet<string>): void {
state().liveAgents.set(chatId, agent);
restorePendingDeliveries(chatId);
for (const entry of state().queues.get(chatId) ?? []) {
if (allowDeferred) entry.deferred = false;
if (entry.waking && wakeSteerIds?.has(entry.steer.id)) entry.wakeAgent = agent;
}
handOffEligible(chatId, agent);
}
@@ -326,7 +366,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
* unregisterLiveAgent, with the run's persisted messages. Two jobs:
*
* - entries whose marker the run DELIVERED (in-run follow-up injection —
* their ids are in the persisted slice) resolve and flip their jobs;
* their ids are in the persisted slice) resolve and acknowledge delivery;
* - entries handed to the dying run but never injected (the question
* stopper exited before the follow-up poll; an abort; a delivery that
* raced the final poll) return to PENDING (handedTo cleared) and the
@@ -335,7 +375,7 @@ export function restoreUncommittedAutomatic(chatId: string, agent: Agent, automa
* This is what makes the live hand-off safe: pi owns no retry for a marker
* stranded in a settled agent's queue, so the registry stays the truth and
* the dead agent's copy is simply abandoned (GC'd with the agent). */
export function settleSteers(chatId: string, persisted: { id?: string }[]): void {
export function settleSteers(chatId: string, persisted: { id?: string }[], failed = false, settledAgent?: Agent): void {
const queue = state().queues.get(chatId);
if (!queue || queue.length === 0) return;
const deliveredIds = new Set(persisted.map((m) => m.id).filter(Boolean));
@@ -345,7 +385,11 @@ export function settleSteers(chatId: string, persisted: { id?: string }[]): void
finalizeSteer(entry.steer);
entry.resolve();
} else {
entry.handedTo = undefined;
const handedHere = entry.handedTo !== undefined && (!settledAgent || entry.handedTo === settledAgent);
const wakeHere = settledAgent !== undefined && entry.wakeAgent === settledAgent;
if (handedHere) entry.handedTo = undefined;
if (wakeHere) { entry.waking = false; entry.wakeAgent = undefined; }
if (failed && (handedHere || wakeHere) && jobIdOf(entry.steer)) entry.deferred = true;
remaining.push(entry);
}
}
@@ -373,16 +417,10 @@ export interface AbortOutcome {
* runner registered it at loop start) — the in-flight model call
* aborts, not just the next loop check. The runner unwinds silently
* (its catch checks abortSignal.aborted; finishJob checks status).
* 2. DROP any queued result steer for this job (the delivering-result
* phase): resolve its entry without firing the wake. If the wake
* turn is ALREADY in flight (the pump shifted the entry), it's too
* late — the result steer lands, and the cancellation steer dedupes
* against it via steerDelivered (same jobId metadata).
* 3. FLIP the record to error "Aborted by learner" immediately — the
* poll reflects the user's action at once (the user chose
* error-with-reason over a distinct cancelled status). finalizeJob
* never overwrites a non-running status, so the flip is terminal no
* matter what the wake does.
* 2. REFUSE if computation already finished. Queued results belong to
* the session, not to an abortable background worker.
* 3. SAVE error "Aborted by learner" and a pending abort delivery together.
* Delivery acknowledgement never overwrites the worker's outcome.
* 4. STEER the teacher: a one-line abort note (buildAbortSteer) through
* the same wake pipeline, so the teacher's promise of a result
* doesn't hang silently in the lesson.
@@ -394,28 +432,18 @@ export function abortJob(jobId: string): AbortOutcome {
const job = stores.jobStore.getJob(jobId);
if (!job) return { ok: false, reason: "not_found" };
if (job.status !== "running") return { ok: false, reason: "not_running" };
fireJobAbort(jobId); // no-op when the loop already returned (delivering)
// Drop any queued steers for this job (their deliver() resolves unfired).
// HANDED entries (already in a live run's follow-up queue) are past
// recall — same ruling as a fired wake: the result lands, and the abort
// note below dedupes against it via steerDelivered (same jobId).
const queue = state().queues.get(job.chatId);
if (queue) {
const remaining = queue.filter(
(e) => jobIdOf(e.steer) !== jobId || e.handedTo !== undefined,
);
if (remaining.length === 0) state().queues.delete(job.chatId);
else state().queues.set(job.chatId, remaining);
for (const entry of queue) {
if (jobIdOf(entry.steer) === jobId && entry.handedTo === undefined) entry.resolve();
}
if (job.result) {
normalizeLegacyResult(job);
return { ok: false, reason: "not_running" };
}
stores.jobStore.updateJob(jobId, { status: "error", error: "Aborted by learner", activity: "aborted" });
fireJobAbort(jobId);
stores.jobStore.updateJob(jobId, {
status: "error", error: "Aborted by learner", activity: "aborted",
delivery: { kind: "abort", status: "pending" },
});
void deliverJobResult(buildAbortSteer(job)).catch(() => {
/* deliverJobResult marks the job error on unrecoverable failure */
/* The saved pending abort notice remains owned by the session. */
});
return { ok: true };
}
@@ -470,18 +498,9 @@ async function pump(chatId: string): Promise<void> {
// Idempotent delivery: the transcript already carries these steers
// (an in-run injection persisted, a wake completed, or a duplicate
// deliver raced in). Resolve and flip without re-firing anything.
const stillPending = queue.filter((e) => !steerDelivered(record.messages, e.steer.id));
for (const entry of queue) {
if (!stillPending.includes(entry)) {
finalizeSteer(entry.steer);
entry.resolve();
}
}
if (stillPending.length === 0) {
s.queues.delete(chatId);
return;
}
s.queues.set(chatId, stillPending);
noteSteersDurable(chatId, record.messages);
const stillPending = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred);
if (stillPending.length === 0) { parked = true; return; }
if (!automaticFollowUpAllowed(chatId, s.liveAgents.get(chatId))) { parked = true; return; }
// VEHICLE 1 — THE LIVE RUN: pi's steering queue. The agent of the
@@ -526,28 +545,17 @@ async function pump(chatId: string): Promise<void> {
// The runner is required — chat-handler sets it at init. If some
// module instance missed the wiring, fail loudly rather than hang.
if (!s.turnRunner) {
s.queues.delete(chatId);
const err = new Error("wake pipeline: no turn runner wired (setTurnRunner)");
for (const entry of stillPending) {
failSteer(entry.steer, err);
entry.reject(err);
}
for (const entry of stillPending) deferOrReject(chatId, entry, err);
parked = true;
return;
}
// A live run may have delivered some markers while the gates were
// closed — re-check: only still-missing markers ride the wake.
const fire = stillPending.filter((e) => !steerDelivered(fresh.messages, e.steer.id));
for (const entry of stillPending) {
if (!fire.includes(entry)) {
finalizeSteer(entry.steer);
entry.resolve();
}
}
if (fire.length === 0) {
s.queues.delete(chatId);
return;
}
noteSteersDurable(chatId, fresh.messages);
const fire = (s.queues.get(chatId) ?? []).filter((entry) => !entry.waking && !entry.deferred && !entry.handedTo);
if (fire.length === 0) return;
// THE DISMISSAL PAUSE (owner design 2026-10-03, "A2"): the record
// ends in a user dismissal — the session waits for the USER. Hold
@@ -561,50 +569,37 @@ async function pump(chatId: string): Promise<void> {
return;
}
// DEQUEUE-AT-FIRE (the Wave C lesson from the Wave B regression):
// the carried markers leave the registry BEFORE the turn runs —
// the wake run's OWN follow-up poll must never see its own cargo,
// and a 409 (client raced in) must be able to push them back.
s.queues.delete(chatId);
// Keep ownership through the wake. The flag excludes cargo from
// its own live-agent handoff while duplicate callers share it.
for (const entry of fire) entry.waking = true;
const steers = fire.map((e) => e.steer);
// Fire the wake (every marker as the turn's input appends). A 409 =
// a client turn raced in between idle and firing — push the cargo
// back and retry after backoff (the racing turn's own settlement
// re-pumps if it beats us to the re-queue).
// Fire the wake with cargo still registered. A 409 releases its
// vehicle flag and retries after the existing backoff, without
// appending another registry entry.
const status = await s.turnRunner(chatId, { type: "steers", steers }).catch((err: unknown) => {
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);
s.queues.set(chatId, back);
const saved = stores.chatStore.getChat(chatId);
if (saved) noteSteersDurable(chatId, saved.messages);
const owned = fire.filter((entry) => s.queues.get(chatId)?.includes(entry));
if (status === 403 || status === 409) {
for (const entry of owned) { entry.waking = false; entry.wakeAgent = undefined; }
if (status === 403) { parked = true; return; }
await delay(RETRY_BACKOFF_MS[Math.min(attempt++, RETRY_BACKOFF_MS.length - 1)]!);
continue;
}
attempt = 0;
if (status === 200) {
for (const entry of fire) {
finalizeSteer(entry.steer);
entry.resolve();
}
} else {
const err = new Error(`wake turn failed with status ${status}`);
for (const entry of fire) {
failSteer(entry.steer, err);
entry.reject(err);
for (const entry of owned) {
if (status === 200 && !jobIdOf(entry.steer)) {
// Preserve the non-job probe handoff's acceptance semantics.
completeEntry(chatId, entry);
} else if (status !== 200 || !isTurnRunning(chatId)) {
deferOrReject(chatId, entry, new Error(`wake turn did not persist steer (status ${status})`));
}
// An accepted live turn still owns its cargo until canonical save
// or settlement. HTTP 200 alone is not a durable marker receipt.
}
}
} finally {
@@ -614,7 +609,7 @@ 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 (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined)) void pump(chatId);
if (!parked && automaticFollowUpAllowed(chatId, state().liveAgents.get(chatId)) && q.some((e) => e.handedTo === undefined && !e.waking && !e.deferred)) void pump(chatId);
}
}
@@ -638,25 +633,56 @@ state().stopConnectionWatch = onSubscriptionChange(() => {
}
});
/** Flip the job to its final status now that the lesson has resumed. */
function finalizeJob(jobId: string): void {
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
const result = job.result;
const failed = result ? isSubagentFailure(job.kind, result as never) : true;
stores.jobStore.updateJob(jobId, {
status: failed ? "error" : "done",
activity: failed ? "failed" : "delivered",
});
function deferOrReject(chatId: string, entry: QueuedSteer, error: Error): void {
entry.waking = false;
entry.wakeAgent = undefined;
if (jobIdOf(entry.steer)) {
entry.deferred = true;
console.error(`[steer ${entry.steer.id}] delivery remains pending:`, error.message);
} else {
removeEntry(chatId, entry);
entry.reject(error);
}
}
function failJob(jobId: string, err: Error): void {
// An aborted job stays aborted even when its (abort) steer
// delivery fails — the user's action is the terminal fact, not the
// delivery error. Non-running records are never overwritten here.
const job = stores.jobStore.getJob(jobId);
if (!job || job.status !== "running") return;
stores.jobStore.updateJob(jobId, { status: "error", error: err.message, activity: "delivery failed" });
/** Acknowledge session delivery without changing the worker outcome. */
function finalizeJob(jobId: string): void {
try {
let job = stores.jobStore.getJob(jobId);
if (!job) return;
if (!job.delivery) job = normalizeLegacyResult(job);
if (job.delivery?.status !== "pending") return;
stores.jobStore.updateJob(jobId, { delivery: { ...job.delivery, status: "delivered" } });
} catch (error) {
// The conversation is already durable. A bookkeeping failure must
// never replay that marker or turn a successful worker into a failure.
console.error(`[job ${jobId}] delivery acknowledgement failed:`, error);
}
}
function normalizeLegacyResult(job: JobRecord): JobRecord {
if (job.delivery || job.status !== "running" || !job.result) return job;
const failed = isSubagentFailure(job.kind, job.result as never);
return stores.jobStore.updateJob(job.id, {
status: failed ? "error" : "done", activity: failed ? "failed" : "finished",
delivery: { kind: "result", status: "pending" },
}) ?? job;
}
/** Restore known obligations only; old terminal records are never replayed. */
function restorePendingDeliveries(chatId: string): void {
const record = stores.chatStore.getChat(chatId);
if (!record) return;
noteSteersDurable(chatId, record.messages);
for (const stored of stores.jobStore.listJobs(chatId)) {
const job = isJobLive(stored.id) ? stored : normalizeLegacyResult(stored);
if (job.delivery?.status !== "pending") continue;
const steer = job.delivery.kind === "abort" ? buildAbortSteer(job)
: job.result ? buildSubagentSteer(job, job.result as never) : undefined;
if (!steer) continue;
if (steerDelivered(record.messages, steer.id)) finalizeSteer(steer);
else queueSteer(chatId, steer);
}
}
/**
@@ -669,7 +695,7 @@ function failJob(jobId: string, err: Error): void {
async function waitForIdle(chatId: string): Promise<void> {
for (;;) {
const record = stores.chatStore.getChat(chatId);
if (!record) return;
if (!record || !(state().queues.get(chatId) ?? []).some((entry) => !entry.waking && !entry.deferred && !entry.handedTo)) return;
const blocked = isTurnRunning(chatId) || danglingQuestionOf(record.messages) !== null;
if (!blocked) {
await delay(GRACE_MS);
@@ -699,9 +725,9 @@ const SWEEP_THROTTLE_MS = 30_000;
* hung-but-alive loops beside themselves — the figure-maker incident,
* 2026-09-18). A record WITH a live registration is provably executing
* in THIS process — never touched, however old: a hung first model call
* (zero usage ever billed) stays honestly "running" in the dock until
* END kills it. The abort registration spans loop start to delivery
* completion, so a live registration can never belong to a dead run.
* stays honestly running until END kills it. Terminal workers with known
* pending delivery are restored independently; old terminal records without
* delivery metadata are never inferred to owe a delivery.
*/
/** Re-run a job's sub-agent loop — exposed for the sweep's tests (the
* /api/jobs poll is its only production caller, disabled under NODE_ENV=test). */
@@ -713,18 +739,14 @@ export function recoverStaleJobs(): void {
try {
for (const chat of stores.chatStore.listChats()) {
for (const job of stores.jobStore.listJobs(chat.id)) {
if (job.status !== "running") continue;
if (isJobLive(job.id)) continue; // provably executing here — never re-run beside it
if (isJobLive(job.id)) continue; // never re-run beside live computation
if (!stores.chatStore.getChat(job.chatId)) continue;
if (isTurnRunning(job.chatId)) continue; // a wake turn may be delivering it right now
if (job.result) {
// The wake never fired (or crashed mid-flight): re-deliver.
const steer = buildSubagentSteer(job, job.result as never);
stores.jobStore.updateJob(job.id, { activity: "recovering: re-delivering" });
void deliverJobResult(steer).catch(() => {
/* deliverJobResult marks the job error on failure */
});
} else {
if (isTurnRunning(job.chatId)) continue;
const recovered = normalizeLegacyResult(job);
if (recovered.delivery?.status === "pending") {
restorePendingDeliveries(job.chatId);
void pump(job.chatId);
} else if (job.status === "running" && !job.result) {
// The sub-agent loop itself died with the process: re-spawn.
if (!s.respawn) continue;
stores.jobStore.updateJob(job.id, { activity: "recovering: re-running" });
+10 -4
View File
@@ -248,10 +248,17 @@ export function validateChatSettings(settings: ChatSettings): SettingsValidation
return { ok: true };
}
export function validateModelsSettings(settings: ModelsSettings): SettingsValidation {
/** Validate introduced/replaced model selections, not unchanged saved pins.
* A disconnected subscription loses its available catalog, but its stored
* selections must not prevent repairing another slot or editing thinking.
* Only the SAME slot's provider+id can pass through; runtime admission stays
* separate. Callers without a stored section retain strict catalog checking. */
export function validateModelsSettings(settings: ModelsSettings, stored?: ModelsSettings): SettingsValidation {
for (const name of MODEL_SLOTS) {
const slot = settings[name];
if (!slot) continue;
const previous = stored?.[name]?.model;
if (slot.model && previous && slot.model.provider === previous.provider && slot.model.id === previous.id) continue;
const verdict = validateSlotSpec(name, slot);
if (!verdict.ok) return verdict;
}
@@ -283,9 +290,8 @@ function validateSlotSpec(name: ModelSlotName, slot: { model?: { provider: strin
// runtime — and the clamped value is what lands in the record. The
// server is the truth: the record shows the clamped level after write.
/** Clamp one level against one model (identity when the model is unknown
* or the level already supported — the existence gate rejects unknown
* models separately, so this only ever sees real ones in practice). */
/** Clamp one level against one model. Preserve the preference when metadata
* is unavailable for an unchanged saved pin; runtime clamps after reconnect. */
function clampLevelFor(provider: string, id: string, level: ThinkingLevel): ThinkingLevel {
const model = connectionModel(provider, id);
return model ? clampThinkingLevel(model, level) : level;
+28 -14
View File
@@ -1,6 +1,4 @@
import type { Agent, AgentEvent, AgentMessage } from "@earendil-works/pi-agent-core";
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";
@@ -79,6 +77,10 @@ export interface PiTurnSpec {
* event stream itself is verbatim — provider errors ride their
* message's stopReason/errorMessage through untouched). */
onError?: (error: unknown) => string;
/** Report settlement failure while the views are still open. */
onSettleError?: (error: unknown) => string;
/** Release liveness even if the normal finish seam throws. */
onSettleFailure?: (error: unknown, reportedErrorText?: string) => void;
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
* Receives the agent's FULL final state (native messages) — the caller
* persists the slice past its own base count. */
@@ -119,11 +121,12 @@ export interface PiTurnHandle {
}
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
let settled = false;
let sawAbort = false;
let reportedErrorText: string | undefined;
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
@@ -221,11 +224,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// messages of its own (the prompt is composed per turn), so dropping
// the role wholesale is exact; revisit if prompt-updates ever land.
const settleWith = () => {
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
try {
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
} catch (err) {
console.error("[turn] settlement failed:", err);
try { onSettleFailure?.(err, reportedErrorText); }
catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); }
if (reportedErrorText === undefined) {
reportedErrorText = onSettleError?.(err) ?? "An error occurred.";
send({ type: "error", errorText: reportedErrorText });
}
}
};
const handle = (event: AgentEvent) => {
if (settled) return;
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
// every consumer downstream shares. No uniquifier → verbatim.
const uniqued = uniquifier ? uniquifier.event(event) : event;
@@ -239,15 +253,11 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
if (event.type === "message_end") {
const m = event.message as { role?: string; stopReason?: string } | undefined;
if (m?.role === "assistant" && m.stopReason === "aborted") sawAbort = true;
// Dock fix (owner ruling 2026-09-26): an injected steer marker's
// message_end = the card is visible to the learner — flip the
// job's bookkeeping now (the turn may keep teaching for minutes;
// settleSteers re-runs the flip as a no-op idempotently).
if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
}
if (event.type === "agent_end") {
closeAll();
// Persist/report before the terminal closes every view to new frames.
settleWith();
closeAll();
}
};
requestCycle?.bindRelease(handle);
@@ -273,11 +283,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
// stream as stopReason "error"): surface, close, persist. If the
// agent already settled (agent_end reached before the throw),
// the seam has run — never settle twice.
if (settled) return;
if (settled) {
console.error("[turn] error after settlement:", err);
return;
}
console.error("[turn] agent loop failed:", err);
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
closeAll();
reportedErrorText = onError?.(err) ?? "An error occurred.";
send({ type: "error", errorText: reportedErrorText });
settleWith();
closeAll();
}
})();
+12
View File
@@ -19,12 +19,14 @@ import type {
BackgroundModelRole,
CompactionSettings,
GeneralSettings,
DictationSettings,
GraphSettings,
ModelsSettings,
RoleModelOverride,
} from "../core/settings.ts";
import { BACKGROUND_MODEL_ROLES, COMPACTION_THRESHOLD_FLOOR, EXTRACTION_CHUNK_TOKENS_FLOOR, MODEL_SLOTS } from "../core/settings.ts";
import { WRITABLE_TOOL_KEYS } from "../core/pending.ts";
import { isDictationLanguage } from "../core/dictation.ts";
/** Parsed-or-rejected — `ok: false` carries the 400's error message. */
export type SettingsBodyResult<T> =
@@ -375,6 +377,15 @@ export function parseCompactionSettingsBody(body: unknown): SettingsBodyResult<C
return { ok: true, settings };
}
/** dictation: { language?: one of Nova-3's supported language codes } */
export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<DictationSettings> {
const raw = asRecord(body);
if (!raw) return { ok: false, error: "Invalid JSON body" };
if (raw.language === undefined) return { ok: true, settings: {} };
if (!isDictationLanguage(raw.language)) return { ok: false, error: "Unsupported dictation language" };
return { ok: true, settings: { language: raw.language } };
}
/** The section→parser map for PUT /api/settings/[section] (the route also
* carries the special prompts sub-resource). */
export const SETTINGS_SECTIONS = {
@@ -385,6 +396,7 @@ export const SETTINGS_SECTIONS = {
graph: parseGraphSettingsBody,
compaction: parseCompactionSettingsBody,
models: parseModelsSettingsBody,
dictation: parseDictationSettingsBody,
} as const;
export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;
+6 -3
View File
@@ -21,6 +21,7 @@
*/
import { emit } from "../core/events.ts";
import { clearTurnFailure } from "./turn-runs.ts";
interface LhGlobal {
__lhTurnRegistry?: Map<string, number>;
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
*/
export function beginTurn(chatId: string): boolean {
if (isTurnRunning(chatId)) return false;
clearTurnFailure(chatId);
inFlight.set(chatId, Date.now());
emit("turn-started", { chatId });
return true;
}
/**
* Mark the chat's turn as finished (runs in onFinish, aborted or not —
* AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
* durable truth). Emits only when an entry was actually present: a
* Mark the chat's turn as finished after normal settlement, or after the
* failure notice has been retained by settlement cleanup. A subsequent
* fetch reads durable truth, never a promise that an unsaved reply landed.
* Emits only when an entry was actually present: a
* defensive no-op endTurn must not fire a spurious settle.
*/
export function endTurn(chatId: string): void {
+30 -5
View File
@@ -13,7 +13,9 @@
* Registration lifecycle: chat-handler registers the handle the moment
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
* — after persist, usage, extraction, and endTurn — so anything that sees
* "no running turn" also sees an already-persisted record. The per-chat
* "no running turn" sees durable record truth. Failed settlement also
* retains its small notice before unregistering, so late attachments do
* not mistake an unsaved reply for success. The per-chat
* 409 guard means a chatId can hold at most one handle, and beginTurn's
* 30-minute TTL bounds the pathological never-settling case the same way
* it does for the liveness map: if onFinish never fires, the stale entry
@@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts";
interface LhTurnRunsGlobal {
__lhTurnRuns?: Map<string, PiTurnHandle>;
__lhTurnFailures?: Map<string, string>;
}
function runs(): Map<string, PiTurnHandle> {
@@ -37,6 +40,25 @@ function runs(): Map<string, PiTurnHandle> {
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
}
/** One small, transient notice per failed session. Never stores messages.
* Cleared on the next accepted begin, session deletion, or process exit. */
function failures(): Map<string, string> {
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
return (g.__lhTurnFailures ??= new Map<string, string>());
}
export function rememberTurnFailure(chatId: string, errorText: string): void {
failures().set(chatId, errorText);
}
export function turnFailure(chatId: string): string | undefined {
return failures().get(chatId);
}
export function clearTurnFailure(chatId: string): void {
failures().delete(chatId);
}
/** Register the chat's running turn (chat-handler, right after the turn
* starts). One per chat by construction — the 409 guard. */
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
@@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
* turn (raced settle, or the client checked turnInFlight a beat too
* late): a same-dialect SSE response whose single chunk tells the client
* "nothing to attach to — the record is current, refetch it". Chosen as
* an EVENT (not a status code) so the reattach path consumes ONE wire
* "nothing to attach to — refetch durable truth". A retained settlement
* failure is delivered first, including when no view attached in time.
* Chosen as an EVENT (not a status code), consuming ONE wire
* protocol end to end. */
export function settledTurnResponse(): Response {
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
export function settledTurnResponse(chatId?: string): Response {
const errorText = chatId ? turnFailure(chatId) : undefined;
const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`;
return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
+13 -4
View File
@@ -1,5 +1,5 @@
/**
* The chat-turn services — one named, error-contained seam per turn
* The chat-turn services — one named seam per turn
* responsibility, extracted from the chat-handler so it composes instead of
* implementing. Each takes its dependencies explicitly (storage ports, the
* job runner) — no module here imports an adapter.
@@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts";
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
/** Distinguish a failed canonical save from later bookkeeping failures. */
export class TurnPersistenceError extends Error {
constructor(cause: unknown) {
super("The reply could not be saved.", { cause });
this.name = "TurnPersistenceError";
}
}
/**
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
* turn's own messages — the agent loop's native history, provider/model/
@@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti
* - Plan blocks: one frozen snapshot per successful plan_init call,
* idempotent (persist replays are safe).
*
* Returns the stamped new messages (usage provenance reads them).
* Returns the stamped new messages (usage provenance reads them). Throws
* TurnPersistenceError on failure so the runner can report it before DONE.
*/
export function persistTurn(
chatStore: ChatStore,
@@ -56,7 +65,7 @@ export function persistTurn(
): LhMessage[] {
try {
const record = chatStore.getChat(chatId);
if (!record) return [];
if (!record) throw new Error(`Chat not found: ${chatId}`);
const stamped: LhMessage[] = [];
for (const m of turnMessages.slice(baseCount)) {
const message = m as LhMessage;
@@ -79,7 +88,7 @@ export function persistTurn(
return stamped;
} catch (err) {
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
return [];
throw new TurnPersistenceError(err);
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts";
import { webSubscriptionOwner, webSubscriptionsEnabled, webSubscriptionStatus } from "./web-subscriptions.ts";
const HEADERS = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff" };
/** Same-origin browser controls, not user authentication. Web/dev deliberately
* retains the existing shared-server/API-key trust model. Never trust forwarded
* origins or add CORS grants here. A public deployment needs its own access gate. */
export function subscriptionRequestOrigin(request: Request): string | undefined {
try {
const url = new URL(request.url);
const host = request.headers.get("host") ?? url.host;
const addressed = new URL(`${url.protocol}//${host}`);
if (!["http:", "https:"].includes(addressed.protocol) || addressed.username || addressed.password || addressed.pathname !== "/" || addressed.search || addressed.hash) return;
return addressed.origin;
} catch { return; }
}
function authorized(request: Request): boolean {
const origin = subscriptionRequestOrigin(request);
if (!origin || request.headers.get("x-learnharness-subscriptions") !== "1") return false;
if (request.headers.get("sec-fetch-site") === "cross-site") return false;
const supplied = request.headers.get("origin");
return request.method === "GET" ? !supplied || supplied === origin : supplied === origin;
}
function error(message: string, status: number) { return Response.json({ error: message }, { status, headers: HEADERS }); }
export async function subscriptionControlsResponse(request: Request): Promise<Response> {
if (!webSubscriptionsEnabled()) return error("Web subscription controls are unavailable in desktop-owned mode.", 404);
if (!authorized(request)) return error("Subscription controls require a same-origin LearnHarness request.", 403);
try {
if (request.method === "GET") return Response.json(await webSubscriptionStatus(), { headers: HEADERS });
if (request.method !== "POST") return error("Unsupported method.", 405);
if (request.headers.get("content-type")?.split(";")[0]?.trim() !== "application/json") return error("Expected JSON.", 415);
// Bound both advertised and streamed input before parsing commands.
if (Number(request.headers.get("content-length")) > 16384) return error("Request too large.", 413);
const reader = request.body?.getReader();
if (!reader) return error("Expected a command.", 400);
const chunks: Uint8Array[] = []; let size = 0;
try {
for (;;) {
const next = await reader.read(); if (next.done) break;
size += next.value.byteLength;
if (size > 16384) { await reader.cancel(); return error("Request too large.", 413); }
chunks.push(next.value);
}
} finally { reader.releaseLock(); }
const command: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!command || typeof command !== "object" || Array.isArray(command)) return error("Invalid command.", 400);
const fields = command as Record<string, unknown>;
const providerId = fields.providerId;
if (providerId !== "chatgpt" && !NATIVE_SUBSCRIPTIONS.some((provider) => provider.providerId === providerId)) return error("Unsupported connection.", 400);
const allowed = providerId === "chatgpt" ? ["login", "cancel", "select", "disconnect", "logout", "consent"] : ["login", "cancel", "respond", "open", "retry", "disconnect", "consent"];
if (typeof fields.op !== "string" || !allowed.includes(fields.op)) return error("Unsupported control.", 400);
return Response.json(await webSubscriptionOwner().control(providerId as string, fields, subscriptionRequestOrigin(request)!), { headers: HEADERS });
} catch {
return error("The subscription connection could not be changed. Retry or reconnect in Providers.", 400);
}
}
/** One-use same-origin navigation. Full provider authorization URLs go only to
* a top-level browser redirect, never status JSON, the SSE bus or application JS. */
export async function subscriptionBrowserResponse(request: Request): Promise<Response> {
if (!webSubscriptionsEnabled()) return error("Unavailable.", 404);
const origin = subscriptionRequestOrigin(request);
if (!origin || (request.method === "POST" ? !authorized(request) : request.headers.get("sec-fetch-site") !== "same-origin")) return error("Open sign-in from LearnHarness.", 403);
const id = new URL(request.url).searchParams.get("id");
if (!id || !/^[0-9a-f-]{36}$/.test(id)) return error("This sign-in action is no longer available.", 410);
const target = await webSubscriptionOwner().takeBrowserAction(id, origin);
if (!target) return error("This sign-in action is no longer available.", 410);
return new Response(null, { status: 303, headers: { ...HEADERS, Location: target } });
}
+127
View File
@@ -0,0 +1,127 @@
/** Persistent Node web/dev owner. Never imported by client code. Desktop keeps
* its existing main-process owner; a broken desktop IPC channel cannot fall
* back to these files. One server process must own each configured auth dir. */
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
import path from "node:path";
import { AUTH_FILE } from "../../config.ts";
import { createChatGPT, fileStore } from "../../electron/chatgpt.cjs";
import { createSubscriptions } from "../../electron/subscriptions.cjs";
import { chatGPTControl } from "../../electron/chatgpt-ipc.cjs";
import { subscriptionControl } from "../../electron/subscription-ipc.cjs";
import { emit } from "../core/events.ts";
import type { ChatGPTStatus } from "../core/contracts/chatgpt.ts";
import type { NativeSubscriptionSnapshot } from "../core/contracts/subscriptions.ts";
import { configureWebSubscriptionRuntime, publishWebSubscriptionPacket, webSubscriptionsEnabled, type SubscriptionRuntimeChannel as Channel, type SubscriptionRuntimePacket as Packet } from "../agent/subscription-runtime.ts";
export { webSubscriptionsEnabled } from "../agent/subscription-runtime.ts";
interface BrowserAction { target: string; origin: string; scope: string; expires: number; current(): Promise<boolean> }
const KEY = Symbol.for("lh.webSubscriptions");
interface Registry {
owner?: ReturnType<typeof createWebSubscriptionOwner>;
}
function registry(): Registry {
const global = globalThis as typeof globalThis & { [KEY]?: Registry };
return global[KEY] ??= {};
}
function publish(channel: Channel, packet: Packet) {
publishWebSubscriptionPacket(channel, packet);
emit("subscriptions-changed", {});
}
async function loadSDK() {
const [sdk, copilot, xai] = await Promise.all([
import("@earendil-works/pi-ai"),
import("@earendil-works/pi-ai/providers/github-copilot"),
import("@earendil-works/pi-ai/providers/xai"),
]);
return { ...sdk, ...copilot, ...xai };
}
/** Injectable factories/stores keep all test authentication offline. */
export function createWebSubscriptionOwner(options: {
directory: string;
chatgptStore?: Parameters<typeof createChatGPT>[0]["store"];
sdk?: unknown;
onPacket?: (channel: Channel, packet: Packet) => void;
} ) {
const notify = options.onPacket ?? (() => {});
const launches = new AsyncLocalStorage<string[]>();
const actions = new Map<string, BrowserAction>();
let chatGPTLaunch = 0;
const openExternal = async (target: string) => {
const captured = launches.getStore();
if (!captured || new URL(target).protocol !== "https:") throw new Error("No browser action is pending.");
captured.push(target);
};
const chatgpt = createChatGPT({
store: options.chatgptStore ?? fileStore(path.join(options.directory, "chatgpt-credentials.json")),
openExternal,
onChange: (packet?: Packet) => { if (packet) notify("lh:chatgpt", packet); },
});
const native = createSubscriptions({
directory: options.directory, sdk: options.sdk, loadSDK, openExternal,
onChange: (snapshot: NativeSubscriptionSnapshot) => notify("lh:subscriptions", { channel: "lh:subscriptions", op: "changed", snapshot }),
});
return {
chatgpt, native,
async control(providerId: string, command: Record<string, unknown>, origin: string) {
const targets: string[] = [];
const value = await launches.run(targets, () => providerId === "chatgpt"
? chatGPTControl(command, chatgpt)
: subscriptionControl({ ...command, providerId }, native));
let browserAction: string | undefined;
const target = targets[0];
if (target) {
const scope = providerId === "chatgpt" ? "chatgpt" : `${providerId}:${command.attemptId}:${command.actionId}`;
for (const [id, action] of actions) if (action.expires <= Date.now() || action.scope === scope) actions.delete(id);
const id = randomUUID();
const generation = providerId === "chatgpt" ? ++chatGPTLaunch : chatGPTLaunch;
const current = providerId === "chatgpt"
? async () => generation === chatGPTLaunch && (await chatgpt.status()).pending === true
: async () => (await native.status()).some((status: { providerId: string; interaction?: { attemptId: string; browser?: { id: string } } }) => status.providerId === providerId && status.interaction?.attemptId === command.attemptId && status.interaction?.browser?.id === command.actionId);
actions.set(id, { target, origin, scope, expires: Date.now() + 180000, current });
browserAction = `/api/subscriptions/browser?id=${id}`;
}
return { value, ...(browserAction ? { browserAction } : {}) };
},
async takeBrowserAction(id: string, origin: string): Promise<string | undefined> {
const action = actions.get(id);
if (!action || action.origin !== origin) return;
actions.delete(id);
if (action.expires <= Date.now() || !await action.current()) return;
return action.target;
},
close() { actions.clear(); chatgpt.close(); native.close(); },
};
}
export function webSubscriptionOwner() {
if (!webSubscriptionsEnabled()) throw new Error("Web subscription controls are unavailable in desktop-owned mode.");
const state = registry();
if (!state.owner) {
state.owner = createWebSubscriptionOwner({ directory: path.dirname(AUTH_FILE), onPacket: publish });
process.once("exit", () => state.owner?.close());
}
return state.owner;
}
/** Private runtime calls only: no HTTP lease/token operation exists. */
export async function webSubscriptionRequest<T>(channel: Channel, op: string, fields: Record<string, unknown>): Promise<T> {
const owner = webSubscriptionOwner();
let value: unknown;
if (channel === "lh:chatgpt") {
if (op === "status") value = { status: await owner.chatgpt.status(), ...owner.chatgpt.stamp() };
else if (op === "models") value = await owner.chatgpt.models();
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.chatgpt.lease(fields.background, fields);
else throw new Error("Invalid private operation.");
} else {
if (op === "snapshot") value = await owner.native.snapshot();
else if (op === "lease" && typeof fields.background === "boolean") value = await owner.native.lease(fields.scope, fields.background);
else throw new Error("Invalid private operation.");
}
return value as T;
}
configureWebSubscriptionRuntime(webSubscriptionRequest);
export async function webSubscriptionStatus(): Promise<{ chatgpt: ChatGPTStatus; native: NativeSubscriptionSnapshot["statuses"] }> {
const owner = webSubscriptionOwner();
const [chatgpt, native] = await Promise.all([owner.chatgpt.status(), owner.native.status()]);
return { chatgpt, native };
}
+1
View File
@@ -35,6 +35,7 @@ function createJob(init: CreateJobInit): JobRecord {
kind: init.kind,
chatId: init.chatId,
toolCallId: init.toolCallId,
...(init.input !== undefined ? { input: init.input } : {}),
status: "running",
activity: init.activity ?? "starting",
createdAt: now,