import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { glob, lstat } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionCommandContext, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { stripFrontmatter } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { ApplicationStatus } from "../src/application/application.js"; import type { ExtractContext, ExtractContextRequest, ExtractProgressEvent, ExtractPublicationInput, ExtractPublicationResult, GradeSettlementInput, GradingContext, GradingResult, IngestContext, IngestContextRequest, LintContext, QuizContext, QuizDetailRecord, QuizEvidenceRecord, QuizEvidenceRequest, QuizPublicationInput, SourceRequest, WikiChangeInput, WikiChangeResult, } from "../src/contracts.js"; type WikiNoteInput = { readonly path: string; readonly title?: string; readonly description?: string; readonly body: string; readonly pageId?: string; readonly quizWorthiness?: "eligible" | "skip" | "unknown"; }; type WikiNoteUpdateInput = { readonly path?: string; readonly title?: string; readonly description?: string; readonly body?: string; readonly quizWorthiness?: "eligible" | "skip" | "unknown"; readonly expectedDigest?: string; }; type LifecycleKind = "extract" | "ingest" | "lint" | "daily" | "quiz-grader" | "sync"; type ExtractWorkflowState = { readonly requestId: string; readonly expectedClaimKeys: ReadonlySet; readonly attemptedClaimKeys: ReadonlySet; readonly completedClaimKeys: ReadonlySet; readonly failed: boolean; readonly replayContext?: ExtractContext; readonly finalizedReplay?: boolean; }; type ContextWorkflowState = { readonly requestId: string; readonly remaining: number; readonly replayContext?: IngestContext | LintContext | QuizContext; readonly finalizedReplay?: boolean; }; type WorkflowState = ContextWorkflowState | ExtractWorkflowState; type WorkflowFinishOptions = { readonly progress?: number; readonly message?: string; readonly errorCode?: string; readonly errorMessage?: string; }; type VaultPaths = { readonly vaultRoot: string }; type ScholarApplication = { readonly paths: VaultPaths; readonly status: () => Promise; readonly stageSource: (request: SourceRequest) => Promise; readonly createNote: (input: WikiNoteInput) => Promise; readonly updateNote: (pageId: string, input: WikiNoteUpdateInput) => Promise; readonly removalPreview: (sourceId: string) => Promise; readonly removeSource: (sourceId: string, confirmationId: string) => Promise; readonly searchWiki: ( query: string, options?: { readonly mode?: "semantic" | "lexical" | "exact"; readonly limit?: number }, ) => Promise; readonly reportIssue: (input: Record) => Promise; readonly beginWorkflow: (kind: LifecycleKind) => Promise<{ readonly workflow: { readonly requestId: string } }>; readonly updateWorkflow: ( requestId: string, input: { readonly progress?: number; readonly message?: string }, ) => Promise; readonly finishWorkflow: ( requestId: string, status: "succeeded" | "failed", options?: WorkflowFinishOptions, ) => Promise; readonly recoverAbandonedWorkflows: () => Promise; readonly getExtractContext: ( input?: ExtractContextRequest, observer?: (event: ExtractProgressEvent) => void | Promise, ) => Promise; readonly publishExtraction: (input: ExtractPublicationInput) => Promise; readonly getIngestContext: (input?: IngestContextRequest) => Promise; readonly getLintContext: (input?: { readonly description?: string }) => Promise; readonly applyWikiChange: (input: WikiChangeInput) => Promise; readonly applyIngestChange: (input: WikiChangeInput, workflowRequestId?: string) => Promise; readonly getQuizEvidence: (input: QuizEvidenceRequest) => Promise; readonly getQuizContext: (input?: { readonly date?: string }) => Promise; readonly publishQuiz: (input: QuizPublicationInput) => Promise; readonly getGradingContext: (input?: { readonly date?: string }, ownerToken?: string) => Promise; readonly settleGrade: (input: GradeSettlementInput, ownerToken?: string) => Promise; readonly close?: () => Promise | void; }; const workflowStates = new Map(); interface RuntimeModule { readonly createApplication: (options: { readonly paths: VaultPaths }) => ScholarApplication; readonly resolveVault: (path?: string) => VaultPaths; } const sourceInput = Type.Object({ kind: Type.Optional( Type.Union([ Type.Literal("document"), Type.Literal("url"), Type.Literal("text"), Type.Literal("pasted"), Type.Literal("note"), Type.Literal("code"), Type.Literal("directory"), Type.Literal("repository"), ]), ), path: Type.Optional(Type.String()), filePath: Type.Optional(Type.String()), url: Type.Optional(Type.String()), text: Type.Optional(Type.String()), name: Type.Optional(Type.String()), displayName: Type.Optional(Type.String()), originalName: Type.Optional(Type.String()), mediaType: Type.Optional(Type.String()), }); const WIKI_MARKDOWN_AUTHORING = "For model-authored Markdown, use $...$ for inline LaTeX and put opening and closing $$ delimiters on separate lines around display LaTeX; never wrap formulas in backticks or use \\(...\\) or \\[...\\]. Put every code, pseudocode, or command example in a fenced code block with an accurate language tag; inline code is only for a single literal identifier, path, command name, or token. Include a fenced Mermaid diagram only when it materially clarifies a relationship, process, state transition, architecture, data flow, or algorithm; keep it focused, cite source-grounded claims nearby, and never add diagrams by quota, for decoration, or to repeat prose, tables, equations, or another diagram."; const noteInput = Type.Object({ pageId: Type.Optional(Type.String({ minLength: 1 })), path: Type.Optional(Type.String()), title: Type.Optional(Type.String()), description: Type.Optional( Type.String({ minLength: 1, description: "Compact OKF selection summary metadata for this page, not source evidence or instructions. Optional for skip/unknown; required and non-empty when the resulting quizWorthiness is eligible. For an eligible update, omit only to preserve the existing valid description or provide a non-empty replacement. Eligible pages also require a renderable body.", }), ), body: Type.Optional( Type.String({ description: `Complete Markdown body. Preserve user-authored prose; model-authored source notes must be self-contained textbook-style exposition with nearby source-chunk citations. An eligible page must have a renderable body. ${WIKI_MARKDOWN_AUTHORING}`, }), ), content: Type.Optional( Type.String({ description: `Alias for body. Preserve user-authored prose; model-authored source notes must teach the topic in depth rather than summarize it. An eligible page must have a renderable body. ${WIKI_MARKDOWN_AUTHORING}`, }), ), quizWorthiness: Type.Optional(Type.Union([Type.Literal("eligible"), Type.Literal("skip"), Type.Literal("unknown")])), }); const removalInput = Type.Object({ sourceId: Type.String(), confirmationId: Type.Optional(Type.String()), confirm: Type.Optional(Type.Boolean()), }); const searchInput = Type.Object({ query: Type.String(), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), }); const statusInput = Type.Object({}); const emptyInput = Type.Object({}); const extractContextInput = Type.Object({ pendingSourceIds: Type.Optional( Type.Array(Type.String({ minLength: 1 }), { maxItems: 3, description: "Exact pending source IDs to claim in caller order. Omit to claim the canonical next batch of at most three.", }), ), }); const ingestContextInput = Type.Object({ sourceIds: Type.Optional( Type.Array(Type.String({ minLength: 1 }), { description: "Exact published source IDs to include. Omit for every published verified packet; page and issue scope is unchanged.", }), ), }); const extractionInput = Type.Object({ claimId: Type.String({ minLength: 1 }), preparedId: Type.String({ minLength: 1 }), digest: Type.String({ minLength: 1 }), endpoints: Type.Array(Type.Integer({ minimum: 1 }), { minItems: 1 }), }); const wikiChangeIssuePageInput = Type.Object({ pageId: Type.String({ minLength: 1 }), expectedDigest: Type.String({ minLength: 1 }), title: Type.Optional(Type.String()), description: Type.Optional( Type.String({ minLength: 1, description: "Compact OKF selection summary metadata for this page, not source evidence or instructions. Optional for skip/unknown; when resolve-issue leaves or makes the page eligible, omit only to preserve the existing valid description or provide a non-empty replacement. Eligible pages also require a renderable body.", }), ), body: Type.Optional( Type.String({ description: `Replacement Markdown body. When resolve-issue leaves or makes the page eligible, the resulting page must have a renderable body. ${WIKI_MARKDOWN_AUTHORING}`, }), ), quizWorthiness: Type.Optional(Type.Union([Type.Literal("eligible"), Type.Literal("skip"), Type.Literal("unknown")])), }); const wikiChangeInput = Type.Union([ Type.Object({ kind: Type.Literal("create-page"), path: Type.String({ minLength: 1 }), title: Type.Optional(Type.String()), description: Type.Optional( Type.String({ minLength: 1, description: "Compact OKF selection summary metadata for this page, not source evidence or instructions. Required and non-empty when quizWorthiness is eligible; optional for skip/unknown. An eligible page also requires a renderable body.", }), ), body: Type.String({ description: `Complete Markdown body; model-authored source pages must teach at textbook depth and cite supporting source chunks. An eligible page must have a renderable body. ${WIKI_MARKDOWN_AUTHORING}`, }), quizWorthiness: Type.Optional( Type.Union([Type.Literal("eligible"), Type.Literal("skip"), Type.Literal("unknown")]), ), }), Type.Object({ kind: Type.Literal("update-page"), pageId: Type.String({ minLength: 1 }), expectedDigest: Type.String({ minLength: 1 }), title: Type.Optional(Type.String()), description: Type.Optional( Type.String({ minLength: 1, description: "Compact OKF selection summary metadata for this page, not source evidence or instructions. Optional for skip/unknown; when the resulting page is eligible, omit only to preserve the existing valid description or provide a non-empty replacement. Eligible pages also require a renderable body.", }), ), body: Type.Optional( Type.String({ description: `Complete replacement Markdown body; model-authored source pages must teach at textbook depth and cite supporting source chunks. An eligible resulting page must have a renderable body. ${WIKI_MARKDOWN_AUTHORING}`, }), ), quizWorthiness: Type.Optional( Type.Union([Type.Literal("eligible"), Type.Literal("skip"), Type.Literal("unknown")]), ), }), Type.Object({ kind: Type.Literal("rename-page"), pageId: Type.String({ minLength: 1 }), expectedDigest: Type.String({ minLength: 1 }), path: Type.String({ minLength: 1 }), }), Type.Object({ kind: Type.Literal("prerequisites"), pageId: Type.String({ minLength: 1 }), prerequisitePageIds: Type.Array(Type.String({ minLength: 1 })), expectedRevision: Type.Optional(Type.Integer({ minimum: 1 })), }), Type.Object({ kind: Type.Literal("resolve-issue"), issueId: Type.String({ minLength: 1 }), page: wikiChangeIssuePageInput, resolution: Type.String({ minLength: 1 }), }), Type.Object({ kind: Type.Literal("retire-page"), pageId: Type.String({ minLength: 1 }), expectedDigest: Type.String({ minLength: 1 }), }), ]); const ingestApplyInput = Type.Object({ workflowRequestId: Type.String({ minLength: 1, description: "Opaque ingest workflow ID returned by scholar_get_ingest_context; pass it unchanged.", }), change: wikiChangeInput, }); const quizQuestionPageInput = Type.Object({ pageId: Type.String({ minLength: 1 }), criterion: Type.String({ minLength: 1 }), weight: Type.Number({ exclusiveMinimum: 0 }), }); const quizQuestionProposal = Type.Object({ kind: Type.Union([Type.Literal("free-response"), Type.Literal("multiple-choice")]), prompt: Type.String({ minLength: 1 }), choices: Type.Optional(Type.Array(Type.String())), pages: Type.Array(quizQuestionPageInput, { minItems: 1 }), sourceRefs: Type.Array(Type.String()), answerKey: Type.Optional(Type.Unknown()), }); const quizInput = Type.Union([ Type.Object({ status: Type.Literal("published"), date: Type.String({ minLength: 1 }), questions: Type.Array(quizQuestionProposal), }), Type.Object({ status: Type.Literal("skipped"), date: Type.String({ minLength: 1 }), reason: Type.String({ minLength: 1 }), }), ]); const quizEvidenceInput = Type.Object({ date: Type.String({ minLength: 1 }), pageIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }), }); const contextDateInput = Type.Object({ date: Type.Optional(Type.String({ minLength: 1 })) }); const lintContextInput = Type.Object({ description: Type.Optional(Type.String()) }); const gradeReadingInput = Type.Object({ pageId: Type.String({ minLength: 1 }), anchor: Type.String(), heading: Type.Optional(Type.String()), }); const gradePageInput = Type.Object({ pageId: Type.String({ minLength: 1 }), rating: Type.Union([Type.Literal("Again"), Type.Literal("Hard"), Type.Literal("Good"), Type.Literal("Easy")]), feedback: Type.Optional(Type.String()), evidence: Type.Array(Type.String()), readings: Type.Optional(Type.Array(gradeReadingInput)), }); const gradeQuestionInput = Type.Object({ questionId: Type.String({ minLength: 1 }), feedback: Type.Optional(Type.String()), }); const gradeInput = Type.Object({ requestId: Type.String({ minLength: 1 }), date: Type.String({ minLength: 1 }), revision: Type.Integer({ minimum: 1 }), submissionId: Type.String({ minLength: 1 }), questions: Type.Array(gradeQuestionInput), pages: Type.Array(gradePageInput), }); const appCache = new Map>(); const gradingClaimOwner = randomUUID(); async function loadRuntimeModule(): Promise { const application = (await import("../dist/application/application.js")) as unknown as Pick< RuntimeModule, "createApplication" >; const vault = (await import("../dist/vault.js")) as unknown as Pick; return { ...application, ...vault }; } function cancelled(signal: AbortSignal | undefined): void { if (signal?.aborted) throw new DOMException("Operation cancelled", "AbortError"); } function asRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Tool input must be an object"); return value as Record; } function jsonResult(value: unknown): AgentToolResult { const text = typeof value === "string" ? value : (JSON.stringify(value ?? null) ?? String(value)); return { content: [{ type: "text", text }], details: value }; } async function applicationFor(ctx: ExtensionContext): Promise { const module = await loadRuntimeModule(); const paths = module.resolveVault(ctx.cwd); const cached = appCache.get(paths.vaultRoot); if (cached) return cached; const pending = (async () => { const app = module.createApplication({ paths }); try { await app.recoverAbandonedWorkflows(); return app; } catch (error) { try { await app.close?.(); } catch {} throw error; } })(); appCache.set(paths.vaultRoot, pending); try { return await pending; } catch (error) { if (appCache.get(paths.vaultRoot) === pending) appCache.delete(paths.vaultRoot); throw error; } } function progress(onUpdate: AgentToolUpdateCallback | undefined, message: string): void { onUpdate?.({ content: [{ type: "text", text: message }], details: undefined }); } function extractProgress(onUpdate: AgentToolUpdateCallback | undefined, event: ExtractProgressEvent): void { onUpdate?.({ content: [{ type: "text", text: `${event.entry}/${event.total} ${event.filename}: ${event.phase}` }], details: event, }); } async function call( ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, message: string, operation: (app: ScholarApplication) => Promise | T, ): Promise> { cancelled(signal); progress(onUpdate, message); const app = await applicationFor(ctx); cancelled(signal); const result = await operation(app); progress(onUpdate, "Pi Scholar completed"); return jsonResult(result); } function workflowKey(app: ScholarApplication, kind: LifecycleKind): string { return `${app.paths.vaultRoot}:${kind}`; } function lifecycleContextValue(kind: LifecycleKind, requestId: string, value: T): unknown { if (kind !== "ingest") return value; if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("ingest context must be an object"); return { ...value, workflowRequestId: requestId }; } function workflowError(error: unknown): WorkflowFinishOptions { return { errorCode: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : "PI_WORKFLOW_FAILED", errorMessage: error instanceof Error ? error.message : String(error), }; } function extractClaimKey(input: Pick): string { return `${input.claimId}\u0000${input.preparedId}`; } function extractWorkflowFailure(): WorkflowFinishOptions { return { progress: 1, message: "Workflow completed with extraction failures", errorCode: "PI_WORKFLOW_FAILED", errorMessage: "One or more extraction entries failed", }; } function recordExtractAttempt( state: ExtractWorkflowState, claimKey: string | undefined, succeeded: boolean, ): { readonly state: ExtractWorkflowState; readonly accepted: boolean; readonly finished: boolean } { if (!claimKey || !state.expectedClaimKeys.has(claimKey)) return { state, accepted: false, finished: false }; if (state.attemptedClaimKeys.has(claimKey)) return { state, accepted: true, finished: state.attemptedClaimKeys.size === state.expectedClaimKeys.size, }; const attemptedClaimKeys = new Set(state.attemptedClaimKeys); attemptedClaimKeys.add(claimKey); const completedClaimKeys = new Set(state.completedClaimKeys); if (succeeded) completedClaimKeys.add(claimKey); const activeState = { ...state }; if (succeeded) delete activeState.replayContext; const next = { ...activeState, attemptedClaimKeys, completedClaimKeys, failed: state.failed || !succeeded, }; return { state: next, accepted: true, finished: attemptedClaimKeys.size === state.expectedClaimKeys.size, }; } function workflowFinalizationApplied(error: unknown): boolean { if (!error || typeof error !== "object" || !("details" in error)) return false; const details = error.details; return details !== null && typeof details === "object" && "applied" in details && details.applied === true; } function publicationFinalizationApplied(error: unknown): boolean { if (!workflowFinalizationApplied(error) || !error || typeof error !== "object") return false; return "publicationApplied" in error && error.publicationApplied === true; } async function persistExtractAttempt( app: ScholarApplication, key: string, attempt: { readonly state: ExtractWorkflowState; readonly finished: boolean }, ): Promise { if (attempt.finished) { await app.finishWorkflow( attempt.state.requestId, attempt.state.failed ? "failed" : "succeeded", attempt.state.failed ? extractWorkflowFailure() : { progress: 1, message: "Workflow completed" }, ); workflowStates.delete(key); } else { const remaining = attempt.state.expectedClaimKeys.size - attempt.state.attemptedClaimKeys.size; await app.updateWorkflow(attempt.state.requestId, { progress: 0.5, message: `${remaining} extraction(s) remaining`, }); } } async function lifecycleContext( ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, message: string, kind: LifecycleKind, operation: (app: ScholarApplication) => Promise, ): Promise> { cancelled(signal); progress(onUpdate, message); const app = await applicationFor(ctx); cancelled(signal); const key = workflowKey(app, kind); const existingState = workflowStates.get(key); if (existingState && "replayContext" in existingState && existingState.replayContext !== undefined) { const { replayContext, finalizedReplay, ...activeState } = existingState; if (finalizedReplay) { workflowStates.delete(key); return jsonResult(lifecycleContextValue(kind, activeState.requestId, replayContext)); } const automaticallyFinalized = ("remaining" in activeState && activeState.remaining === 0) || ("expectedClaimKeys" in activeState && activeState.expectedClaimKeys.size === 0); if (automaticallyFinalized) { const failed = "expectedClaimKeys" in activeState && activeState.failed; try { await app.finishWorkflow( activeState.requestId, failed ? "failed" : "succeeded", failed ? extractWorkflowFailure() : { progress: 1, message: "Workflow completed" }, ); workflowStates.delete(key); } catch (persistenceError) { if (workflowFinalizationApplied(persistenceError)) workflowStates.set(key, { ...existingState, finalizedReplay: true }); throw persistenceError; } } else { workflowStates.set(key, activeState); } return jsonResult(lifecycleContextValue(kind, activeState.requestId, replayContext)); } if (existingState) throw new Error(`${kind} workflow is already running`); let state: WorkflowState | undefined; let result: T; try { const started = await app.beginWorkflow(kind); const requestId = started.workflow.requestId; state = kind === "extract" ? { requestId, expectedClaimKeys: new Set(), attemptedClaimKeys: new Set(), completedClaimKeys: new Set(), failed: false, } : { requestId, remaining: 1 }; result = await operation(app); } catch (error) { if (state) { workflowStates.set(key, state); try { await app.finishWorkflow(state.requestId, "failed", workflowError(error)); workflowStates.delete(key); } catch (persistenceError) { if (workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, state); } } throw error; } if (!state) throw new Error("workflow state unavailable"); const requestId = state.requestId; if (kind === "extract") { const context = result as ExtractContext; const expectedClaimKeys = new Set( (Array.isArray(context.claims) ? context.claims : []).map((claim) => extractClaimKey(claim)), ); const extractState: ExtractWorkflowState = { requestId, expectedClaimKeys, attemptedClaimKeys: new Set(), completedClaimKeys: new Set(), failed: Array.isArray(context.failures) && context.failures.length > 0, }; workflowStates.set(key, extractState); try { if (expectedClaimKeys.size === 0) { await app.finishWorkflow( requestId, extractState.failed ? "failed" : "succeeded", extractState.failed ? extractWorkflowFailure() : { progress: 1, message: "Workflow completed" }, ); workflowStates.delete(key); } else { await app.updateWorkflow(requestId, { progress: 0.25, message: "Context loaded" }); } } catch (persistenceError) { workflowStates.set(key, { ...extractState, replayContext: context, ...(expectedClaimKeys.size === 0 && workflowFinalizationApplied(persistenceError) ? { finalizedReplay: true } : {}), }); throw persistenceError; } } else { const remaining = kind === "daily" && (result as QuizContext).maintenanceEnabled ? 0 : 1; const nextState: ContextWorkflowState = { requestId, remaining }; workflowStates.set(key, nextState); try { if (remaining === 0) { await app.finishWorkflow(requestId, "succeeded", { progress: 1, message: "Workflow completed" }); workflowStates.delete(key); } else { await app.updateWorkflow(requestId, { progress: 0.25, message: "Context loaded" }); } } catch (persistenceError) { workflowStates.set(key, { ...nextState, replayContext: result as IngestContext | LintContext | QuizContext, ...(remaining === 0 && workflowFinalizationApplied(persistenceError) ? { finalizedReplay: true } : {}), }); throw persistenceError; } } progress(onUpdate, "Pi Scholar completed"); return jsonResult(lifecycleContextValue(kind, requestId, result)); } async function lifecycleFinal( ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, message: string, kind: LifecycleKind, operation: (app: ScholarApplication) => Promise, claimKey?: string, ): Promise> { cancelled(signal); progress(onUpdate, message); const app = await applicationFor(ctx); cancelled(signal); const key = workflowKey(app, kind); const state = workflowStates.get(key); if (!state) throw new Error(`${kind} context is required before the final tool`); let result: T; try { cancelled(signal); result = await operation(app); } catch (error) { if (kind === "ingest" || kind === "lint") { workflowStates.set(key, state); } else if (kind === "extract") { if (publicationFinalizationApplied(error)) { const attempt = recordExtractAttempt(state as ExtractWorkflowState, claimKey, true); workflowStates.set(key, attempt.state); } else { const attempt = recordExtractAttempt(state as ExtractWorkflowState, claimKey, false); if (attempt.accepted) { workflowStates.set(key, attempt.state); try { await persistExtractAttempt(app, key, attempt); } catch (persistenceError) { if (attempt.finished && workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, attempt.state); } } } } else { workflowStates.set(key, state); try { await app.finishWorkflow(state.requestId, "failed", workflowError(error)); workflowStates.delete(key); } catch (persistenceError) { if (workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, state); } } throw error; } if (kind === "ingest" || kind === "lint") { const { replayContext: _replayContext, ...activeState } = state as ContextWorkflowState; workflowStates.set(key, activeState); } else if (kind === "extract") { const attempt = recordExtractAttempt(state as ExtractWorkflowState, claimKey, true); if (attempt.accepted) { workflowStates.set(key, attempt.state); try { await persistExtractAttempt(app, key, attempt); } catch (persistenceError) { if (attempt.finished && workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, attempt.state); throw persistenceError; } } } else { const remaining = (state as { readonly requestId: string; readonly remaining: number }).remaining - 1; if (remaining <= 0) { try { await app.finishWorkflow(state.requestId, "succeeded", { progress: 1, message: "Workflow completed" }); workflowStates.delete(key); } catch (persistenceError) { if (workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, state); throw persistenceError; } } else { await app.updateWorkflow(state.requestId, { progress: 0.5, message: `${remaining} extraction(s) remaining` }); workflowStates.set(key, { requestId: state.requestId, remaining }); } } progress(onUpdate, "Pi Scholar completed"); return jsonResult(result); } async function lifecycleIngestApply( ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, requestId: string, operation: (app: ScholarApplication) => Promise, ): Promise> { const module = await loadRuntimeModule(); const paths = module.resolveVault(ctx.cwd); const state = workflowStates.get(`${paths.vaultRoot}:ingest`); if (!state) throw new Error("ingest context is required before applying"); if (state.requestId !== requestId) throw new Error("ingest workflow ID does not match the current context"); return lifecycleFinal(ctx, signal, onUpdate, "Applying guarded ingest change", "ingest", operation); } async function lifecycleFinish( ctx: ExtensionContext, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, message: string, kind: LifecycleKind, operation: (app: ScholarApplication) => Promise, ): Promise> { cancelled(signal); progress(onUpdate, message); const module = await loadRuntimeModule(); const paths = module.resolveVault(ctx.cwd); const key = `${paths.vaultRoot}:${kind}`; const state = workflowStates.get(key); if (!state) throw new Error(`${kind} context is required before finishing`); const app = await applicationFor(ctx); cancelled(signal); let result: T; try { cancelled(signal); result = await operation(app); } catch (error) { workflowStates.set(key, state); try { await app.finishWorkflow(state.requestId, "failed", workflowError(error)); workflowStates.delete(key); } catch (persistenceError) { if (workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, state); } throw error; } try { await app.finishWorkflow(state.requestId, "succeeded", { progress: 1, message: "Workflow completed" }); workflowStates.delete(key); } catch (persistenceError) { if (workflowFinalizationApplied(persistenceError)) workflowStates.delete(key); else workflowStates.set(key, state); throw persistenceError; } progress(onUpdate, "Pi Scholar completed"); return jsonResult(result); } async function stage(app: ScholarApplication, params: Record): Promise { const input = { ...params }; for (const key of ["path", "filePath", "url", "name", "displayName", "originalName", "mediaType"] as const) if (input[key] === "") delete input[key]; if (typeof input.kind !== "string") { if (typeof input.url === "string") input.kind = "url"; else if (typeof input.text === "string") input.kind = "pasted"; } return app.stageSource(input as unknown as SourceRequest); } async function note(app: ScholarApplication, params: Record): Promise { const body = typeof params.body === "string" ? params.body : typeof params.content === "string" ? params.content : undefined; const title = typeof params.title === "string" ? params.title : undefined; const description = typeof params.description === "string" ? params.description : undefined; const quizWorthinessValue = params.quizWorthiness; const quizWorthiness = quizWorthinessValue === "eligible" || quizWorthinessValue === "skip" || quizWorthinessValue === "unknown" ? quizWorthinessValue : undefined; const path = typeof params.path === "string" ? params.path : undefined; if (typeof params.pageId === "string") { if ( body === undefined && title === undefined && description === undefined && quizWorthiness === undefined && path === undefined ) throw new Error("an update is required"); const update: WikiNoteUpdateInput = { ...(body === undefined ? {} : { body }), ...(title === undefined ? {} : { title }), ...(description === undefined ? {} : { description }), ...(quizWorthiness === undefined ? {} : { quizWorthiness }), ...(path === undefined ? {} : { path }), }; return app.updateNote(params.pageId, update); } if (typeof body !== "string" || !body.trim()) throw new Error("body or content is required"); const input: WikiNoteInput = { path: path ?? "", title, description, body, quizWorthiness }; return app.createNote(input); } async function report(app: ScholarApplication, params: Record): Promise { return app.reportIssue(params); } async function removalPreview(app: ScholarApplication, sourceId: string): Promise { return app.removalPreview(sourceId); } async function remove(app: ScholarApplication, sourceId: string, confirmationId: string): Promise { return app.removeSource(sourceId, confirmationId); } function parseAddArguments(raw: string): string[] { const values: string[] = []; let value = ""; let quote: "'" | '"' | undefined; let started = false; for (let index = 0; index < raw.length; index++) { const character = raw[index]; if (character === undefined) continue; if (character === "\\") { const next = raw[index + 1]; if (next !== undefined && (/\s/u.test(next) || next === "'" || next === '"')) { value += next; index++; } else { value += character; } started = true; } else if (quote !== undefined) { if (character === quote) quote = undefined; else value += character; } else if (character === "'" || character === '"') { quote = character; started = true; } else if (/\s/u.test(character)) { if (started) { values.push(value); value = ""; started = false; } } else { value += character; started = true; } } if (quote !== undefined) throw new Error("Unterminated quote in scholar-add arguments"); if (started) values.push(value); return values; } async function expandAddPaths(patterns: readonly string[], cwd: string): Promise { const matches = new Set(); for (const pattern of patterns) { const absolute = resolve(cwd, pattern); try { await lstat(absolute); matches.add(absolute); continue; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } let matched = false; for await (const match of glob(pattern, { cwd })) { matched = true; matches.add(resolve(cwd, match)); } if (!matched) throw new Error(`No filesystem path matched "${pattern}"`); } return [...matches].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)); } async function handleAddCommand(args: string, ctx: ExtensionCommandContext): Promise { const raw = args.trim() || (ctx.hasUI ? await ctx.ui.input("Add source", "URL, path, or pasted text") : undefined); if (!raw) return; ctx.ui.setStatus("pi-scholar", "Staging source"); try { if (/^https?:\/\//iu.test(raw) || raw.startsWith("text:")) { await call(ctx, ctx.signal, undefined, "Staging source", (app) => stage(app, /^https?:\/\//iu.test(raw) ? { url: raw } : { text: raw.slice(5) }), ); ctx.ui.notify("Source staged in inbox", "info"); return; } const paths = await expandAddPaths(parseAddArguments(raw), ctx.cwd); ctx.ui.setStatus("pi-scholar", paths.length === 1 ? "Staging source" : "Staging sources"); await call(ctx, ctx.signal, undefined, paths.length === 1 ? "Staging source" : "Staging sources", async (app) => { const results: unknown[] = []; for (const path of paths) results.push(await stage(app, { path })); return results.length === 1 ? results[0] : results; }); ctx.ui.notify(paths.length === 1 ? "Source staged in inbox" : `${paths.length} sources staged in inbox`, "info"); } finally { ctx.ui.setStatus("pi-scholar", undefined); } } async function handleIssueCommand(args: string, ctx: ExtensionCommandContext): Promise { const raw = args.trim() || (ctx.hasUI ? await ctx.ui.input("Report wiki issue", "kind:description (incorrect, unclear, missing, or bad-boundary)") : undefined); if (!raw) return; const match = /^(incorrect|unclear|missing|bad-boundary)\s*:\s*(.+)$/iu.exec(raw); const kind = match?.[1]?.toLowerCase() ?? "unclear"; const description = match?.[2]?.trim() ?? raw; await call(ctx, ctx.signal, undefined, "Recording wiki issue", (app) => report(app, { kind, description })); ctx.ui.notify("Wiki issue recorded", "info"); } const STATUS_LABEL_LIMIT = 160; const STATUS_LIST_LIMIT = 5; function statusLabel(value: string | undefined, fallback = "none"): string { const clean = (value ?? "") .replace(/[\u0000-\u001f\u007f-\u009f]+/gu, " ") .replace(/\s+/gu, " ") .trim(); if (!clean) return fallback; const characters = [...clean]; return characters.length <= STATUS_LABEL_LIMIT ? clean : `${characters.slice(0, STATUS_LABEL_LIMIT - 1).join("")}…`; } function statusList(label: string, entries: readonly string[]): string[] { if (entries.length === 0) return [`${label}: none`]; const visible = entries.slice(0, STATUS_LIST_LIMIT); return [ `${label}: ${entries.length}`, ...visible.map((entry) => ` - ${entry}`), ...(entries.length > visible.length ? [` - … ${entries.length - visible.length} more`] : []), ]; } function workflowStatus(workflow: ApplicationStatus["workflows"][number]): string { const timestamp = workflow.finishedAt ?? workflow.startedAt; return [ `${workflow.kind} ${workflow.status}`, workflow.message ? statusLabel(workflow.message) : undefined, timestamp ? statusLabel(timestamp) : undefined, ] .filter((value): value is string => value !== undefined) .join(" — "); } export function formatApplicationStatus(status: ApplicationStatus): string { const facts = status.settings.facts; const git = facts.git; const gitState = git.message && !git.branch ? `unavailable — ${statusLabel(git.message)}` : [ statusLabel(git.branch, "unknown branch"), git.clean ? "clean" : "dirty", git.upstream ? `upstream ${statusLabel(git.upstream)}` : undefined, git.ahead ? `${git.ahead} ahead` : undefined, git.behind ? `${git.behind} behind` : undefined, git.diverged ? "diverged" : undefined, git.message ? statusLabel(git.message) : undefined, ] .filter((value): value is string => value !== undefined) .join(", "); const lastResult = (kind: "Ingest" | "Lint", at?: string, result?: string): string => `${kind}: ${at || result ? [statusLabel(at, "unknown time"), statusLabel(result)].join(" — ") : "never"}`; const active = status.workflows.filter((workflow) => workflow.status === "queued" || workflow.status === "running"); const recent = status.workflows.filter((workflow) => workflow.status !== "queued" && workflow.status !== "running"); return [ `Pi Scholar: ${status.status}`, `Version: ${statusLabel(status.version)}`, `Vault: ${statusLabel(status.vaultId)}`, `Doctor: ${status.doctor ?? "unknown"}`, `Date: ${statusLabel(facts.localDate)} (${statusLabel(status.settings.timezone)})`, ...(status.settings.simulatedDate ? [`Simulated date: ${statusLabel(status.settings.simulatedDate)}`] : []), `Maintenance: ${status.settings.maintenanceEnabled ? "enabled" : "disabled"}`, `Inbox: ${facts.pendingInboxCount} pending`, `Issues: ${facts.openIssueCount} open`, lastResult("Ingest", facts.lastIngestAt, facts.lastIngestResult), lastResult("Lint", facts.lastLintAt, facts.lastLintResult), `Git: ${gitState}`, ...statusList( "Recent changes", facts.recentChanges.map((change) => statusLabel(change)), ), ...statusList("Active workflows", active.map(workflowStatus)), ...statusList("Recent workflows", recent.map(workflowStatus)), ].join("\n"); } async function handleStatusCommand(_args: string, ctx: ExtensionCommandContext): Promise { cancelled(ctx.signal); const app = await applicationFor(ctx); cancelled(ctx.signal); ctx.ui.notify(formatApplicationStatus(await app.status()), "info"); } async function handleLintCommand(pi: ExtensionAPI, args: string, ctx: ExtensionCommandContext): Promise { const description = args.trim() || (ctx.hasUI ? (await ctx.ui.input("Lint wiki", "Full-wiki concern, page, or topic"))?.trim() : undefined); if (!description) return; const location = resolve(dirname(fileURLToPath(import.meta.url)), "../skills/lint/SKILL.md"); let body: string; try { body = stripFrontmatter(readFileSync(location, "utf8")).trim(); } catch { ctx.ui.notify("Bundled lint skill is unavailable", "error"); return; } const skillBlock = `\nReferences are relative to ${dirname(location)}.\n\n${body}\n`; await ctx.waitForIdle(); pi.sendUserMessage(`${skillBlock}\n\n${description}`); } export default function piScholarExtension(pi: ExtensionAPI): void { pi.registerCommand("scholar-add", { description: "Stage one URL, pasted source, or one or more filesystem paths", handler: handleAddCommand, }); pi.registerCommand("scholar-issue", { description: "Report an incorrect, unclear, missing, or badly bounded wiki item", handler: handleIssueCommand, }); pi.registerCommand("scholar-status", { description: "Show Pi Scholar vault, workflow, learning, doctor, and Git facts", handler: handleStatusCommand, }); pi.registerCommand("scholar-lint", { description: "Inspect the final wiki and propose guarded repairs", handler: async (args, ctx) => handleLintCommand(pi, args, ctx), }); pi.registerTool({ name: "scholar_add", label: "scholar_add", description: "Stage a typed source in the Pi Scholar inbox.", executionMode: "sequential", parameters: sourceInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Staging source", (app) => stage(app, asRecord(params))); }, }); pi.registerTool({ name: "scholar_note", label: "scholar_note", description: "Create or update a guarded wiki note. Preserve user prose; model-authored source notes must teach at textbook depth, not merely summarize.", executionMode: "sequential", parameters: noteInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Writing guarded note", (app) => note(app, asRecord(params))); }, }); pi.registerTool({ name: "scholar_remove_source", label: "scholar_remove_source", executionMode: "sequential", description: "Preview source dependents, then remove only after explicit confirmation.", parameters: removalInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { const input = asRecord(params); const sourceId = String(input.sourceId ?? "").trim(); if (!sourceId) throw new Error("sourceId is required"); return call(ctx, _signal, onUpdate, "Inspecting source dependencies", async (app) => { const preview = await removalPreview(app, sourceId); if (input.confirm !== true) return preview; const confirmationId = String(input.confirmationId ?? asRecord(preview).confirmationId ?? ""); if (!confirmationId) throw new Error("A current removal confirmation is required"); if (!ctx.hasUI) throw new Error("Source removal requires interactive confirmation"); const accepted = await ctx.ui.confirm( "Remove source", "This removes current dependent artifacts and does not erase Git history.", ); if (!accepted) return { cancelled: true, preview }; const current = await removalPreview(app, sourceId); if (String(asRecord(current).confirmationId ?? "") !== confirmationId) return { stale: true, preview: current }; return remove(app, sourceId, confirmationId); }); }, }); pi.registerTool({ name: "scholar_search", label: "scholar_search", description: "Search trusted wiki content with qmd semantic ranking.", parameters: searchInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Searching wiki", (app) => app.searchWiki(params.query, { mode: "semantic", limit: params.limit }), ); }, }); pi.registerTool({ name: "scholar_status", label: "scholar_status", description: "Read bounded Pi Scholar status facts.", parameters: statusInput, async execute(_toolCallId, _params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Reading Scholar status", (app) => app.status()); }, }); pi.registerTool({ name: "scholar_get_extract_context", label: "scholar_get_extract_context", executionMode: "sequential", description: "Claim either the exact pending source IDs supplied or the canonical next stable batch of at most three.", parameters: extractContextInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return lifecycleContext(ctx, _signal, onUpdate, "Loading extract context", "extract", (app) => app.getExtractContext(params as ExtractContextRequest, (event) => extractProgress(onUpdate, event)), ); }, }); pi.registerTool({ name: "scholar_publish_extraction", label: "scholar_publish_extraction", executionMode: "sequential", description: "Publish one claimed source extraction with validated 1-based line endpoints.", parameters: extractionInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { const input = params as ExtractPublicationInput; return lifecycleFinal( ctx, _signal, onUpdate, "Publishing extraction", "extract", (app) => app.publishExtraction(input), extractClaimKey(input), ); }, }); pi.registerTool({ name: "scholar_get_ingest_context", label: "scholar_get_ingest_context", executionMode: "sequential", description: "Read the current wiki and either the exact requested published sources or every published verified packet.", parameters: ingestContextInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return lifecycleContext(ctx, _signal, onUpdate, "Loading ingest context", "ingest", (app) => app.getIngestContext(params as IngestContextRequest), ); }, }); pi.registerTool({ name: "scholar_apply_ingest", label: "scholar_apply_ingest", executionMode: "sequential", description: "Apply one guarded source-grounded wiki change under the parent ingest workflow.", parameters: ingestApplyInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { const input = asRecord(params); const requestId = String(input.workflowRequestId ?? "").trim(); if (!requestId) throw new Error("workflowRequestId is required"); const change = asRecord(input.change) as WikiChangeInput; return lifecycleIngestApply(ctx, _signal, onUpdate, requestId, (app) => app.applyIngestChange(change, requestId)); }, }); pi.registerTool({ name: "scholar_finish_ingest", label: "scholar_finish_ingest", executionMode: "sequential", description: "Finish an ingest context after submitting all bounded changes, including none.", parameters: emptyInput, async execute(_toolCallId, _params, _signal, onUpdate, ctx) { return lifecycleFinish(ctx, _signal, onUpdate, "Finishing ingest", "ingest", async () => ({ status: "completed" as const, })); }, }); pi.registerTool({ name: "scholar_get_lint_context", label: "scholar_get_lint_context", executionMode: "sequential", description: "Read the final wiki for a full or targeted lint scope.", parameters: lintContextInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { const input = asRecord(params); const description = typeof input.description === "string" ? input.description.trim() : ""; return lifecycleContext(ctx, _signal, onUpdate, "Loading lint context", "lint", (app) => app.getLintContext(description ? { description } : undefined), ); }, }); pi.registerTool({ name: "scholar_apply_lint", label: "scholar_apply_lint", executionMode: "sequential", description: "Apply one guarded wiki change during lint; split and merge use composed operations.", parameters: wikiChangeInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return lifecycleFinal(ctx, _signal, onUpdate, "Applying guarded lint change", "lint", (app) => app.applyWikiChange(params as WikiChangeInput), ); }, }); pi.registerTool({ name: "scholar_finish_lint", label: "scholar_finish_lint", executionMode: "sequential", description: "Finish a lint context after submitting all bounded changes, including none.", parameters: emptyInput, async execute(_toolCallId, _params, _signal, onUpdate, ctx) { return lifecycleFinish(ctx, _signal, onUpdate, "Finishing lint", "lint", async () => ({ status: "completed" as const, })); }, }); pi.registerTool({ name: "scholar_get_daily_context", label: "scholar_get_daily_context", executionMode: "sequential", description: "Read the current local-date daily quiz context and maintenance guard. If maintenanceEnabled is true, call no other tool and answer exactly: Daily quiz guarded for . Expired prior quizzes: . No quiz was published. Substitute the returned values.", parameters: contextDateInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return lifecycleContext(ctx, _signal, onUpdate, "Loading daily context", "daily", (app) => app.getQuizContext(params), ); }, }); pi.registerTool({ name: "scholar_get_daily_evidence", label: "scholar_get_daily_evidence", executionMode: "sequential", description: "Read authoritative evidence for a selected, currently eligible daily page subset.", parameters: quizEvidenceInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Loading daily evidence", (app) => app.getQuizEvidence(params as QuizEvidenceRequest), ); }, }); pi.registerTool({ name: "scholar_publish_daily", label: "scholar_publish_daily", executionMode: "sequential", description: "Publish one validated daily quiz proposal or explicit skip after an unguarded daily context; never call when maintenance mode is enabled.", parameters: quizInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return lifecycleFinal(ctx, _signal, onUpdate, "Publishing daily quiz", "daily", (app) => app.publishQuiz(params as QuizPublicationInput), ); }, }); pi.registerTool({ name: "scholar_get_grading_context", label: "scholar_get_grading_context", executionMode: "sequential", description: "Read the current sealed quiz revision and submission context.", parameters: contextDateInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Loading grading context", (app) => app.getGradingContext(params, gradingClaimOwner), ); }, }); pi.registerTool({ name: "scholar_settle_grade", label: "scholar_settle_grade", executionMode: "sequential", description: "Settle one validated sealed quiz grading result.", parameters: gradeInput, async execute(_toolCallId, params, _signal, onUpdate, ctx) { return call(ctx, _signal, onUpdate, "Settling quiz grade", (app) => app.settleGrade(params as GradeSettlementInput, gradingClaimOwner), ); }, }); pi.on("agent_end", () => { let remaining = 0; for (const state of workflowStates.values()) if ("expectedClaimKeys" in state) remaining += state.expectedClaimKeys.size - state.attemptedClaimKeys.size; if (remaining > 0) pi.sendUserMessage( `Continue the current extract batch: ${remaining} claimed source(s) still require a scholar_publish_extraction attempt. Do not summarize or stop until every claim has been attempted.`, { deliverAs: "followUp" }, ); }); pi.on("session_shutdown", async () => { workflowStates.clear(); const pending = [...appCache.values()]; appCache.clear(); const apps = await Promise.allSettled(pending); await Promise.all(apps.flatMap((result) => (result.status === "fulfilled" ? [result.value.close?.()] : []))); }); }