diff --git a/docs/GLM_NATIVE_PROMPT.md b/docs/GLM_NATIVE_PROMPT.md index c48cb05..478a771 100644 --- a/docs/GLM_NATIVE_PROMPT.md +++ b/docs/GLM_NATIVE_PROMPT.md @@ -27,7 +27,7 @@ tool-loop expectations. ## Prompt Shape -The shared prompt builder in `src/prompt.js` creates: +The shared prompt builder in `src/prompt.ts` creates: 1. `GLM-NATIVE OPERATING CONTRACT` 2. Existing one-shot working rules or REPL persona diff --git a/docs/design.md b/docs/design.md index a643a4e..5757fce 100644 --- a/docs/design.md +++ b/docs/design.md @@ -69,7 +69,7 @@ LazyGLM itself, not smoke tests around another agent. ### Compaction handoff digest -`src/agent/context.js` compacts by pinning the original user task and replacing +`src/agent/context.ts` compacts by pinning the original user task and replacing the dropped transcript middle with a deterministic digest. The digest is plain text and currently emits sections in this order when data exists: @@ -129,23 +129,23 @@ handoff; correctness still comes from repo inspection, tests, and verification. ```text bin/lazyglm.js CLI entrypoint -src/cli.js command dispatcher (run | chat/REPL | install | uninstall | doctor | models | skills | skill | hook) -src/config.js global user config (~/.lazyglm/config.json, chmod 600; key never in process.env) -src/onboard.js first-run onboarding (provider + key) -src/repl.js interactive REPL (streaming + tools + hooks + sessions) -src/sessions.js session persistence (JSONL under ~/.lazyglm/sessions/) +src/cli.ts command dispatcher (run | chat/REPL | install | uninstall | doctor | models | skills | skill | hook) +src/config.ts global user config (~/.lazyglm/config.json, chmod 600; key never in process.env) +src/onboard.ts first-run onboarding (provider + key) +src/repl.ts interactive REPL (streaming + tools + hooks + sessions) +src/sessions.ts session persistence (JSONL under ~/.lazyglm/sessions/) src/agent/ - provider.js OpenAI-compatible GLM provider (z.ai / Nous / Ollama / custom) - router.js role -> model routing + provider-aware model IDs - tools.js read_file, write_file, patch_file, list_dir, grep, run_shell, finish - runtime.js one-shot tool-use loop: model -> tools -> hooks -> repeat until finish() - context.js message bookkeeping + task-preserving compaction + provider.ts OpenAI-compatible GLM provider (z.ai / Nous / Ollama / custom) + router.ts role -> model routing + provider-aware model IDs + tools.ts read_file, write_file, patch_file, list_dir, grep, run_shell, finish + runtime.ts one-shot tool-use loop: model -> tools -> hooks -> repeat until finish() + context.ts message bookkeeping + task-preserving compaction src/hooks/ hook engine + protocol schema src/plugins/ discipline plugins src/skills/ skill loader for `$command` invocations -src/installer.js `lazyglm install` -src/doctor.js provider/model/routing/plugin/skill health report -src/ulw.js Ultrawork verified-completion loop +src/installer.ts `lazyglm install` +src/doctor.ts provider/model/routing/plugin/skill health report +src/ulw.ts Ultrawork verified-completion loop config/model-catalog.json GLM models, providers, roles, context windows, reasoning effort ``` diff --git a/docs/typescript-migration.md b/docs/typescript-migration.md index ae5dcb6..33a51d5 100644 --- a/docs/typescript-migration.md +++ b/docs/typescript-migration.md @@ -46,7 +46,17 @@ The second migration phase converts the contract-heavy agent boundary modules to - `src/agent/tool-errors.ts` - `src/agent/adaptive-router.ts` -These modules now consume shared contracts from `src/types/index.ts` directly. Provider wire JSON remains module-private typed data, and out-of-scope support modules (`context.js`, `deadline.js`, `thinking.js`) remain JavaScript compiled by the Phase 1 `allowJs` pipeline. +These modules now consume shared contracts from `src/types/index.ts` directly. Provider wire JSON remains module-private typed data, while support modules are converted in the final CLI/REPL phase below. + +### Phase 5 — CLI, REPL, sessions, and runtime support modules + +The final runtime migration phase converts the remaining source JavaScript modules to TypeScript while preserving NodeNext `.js` import specifiers in source: + +- CLI, REPL, session, doctor, installer, update, onboarding, banner, status, and output helpers +- `src/agent/context.ts`, `src/agent/deadline.ts`, and `src/agent/thinking.ts` +- `src/prompt.ts` and `src/scaffold/handoff.ts` + +After this phase, no runtime `.js` source files remain under `src/`. The executable shim stays JavaScript at `bin/lazyglm.js` and continues to import compiled output from `dist/`. Tests that exercise converted boundaries import from `dist/` after `npm run build`, matching the package runtime boundary used by `bin/lazyglm.js`. @@ -55,8 +65,8 @@ Tests that exercise converted boundaries import from `dist/` after `npm run buil These remain outside the Phase 1/2 work: - enabling `checkJs` globally; -- converting agent support, hooks, config, MCP, plugin, installer, CLI, REPL, or test files to `.ts`; -- typechecking additional JavaScript boundaries beyond the current converted agent boundary surface; +- converting test files to `.ts`; +- enabling typechecking for non-runtime JavaScript outside `src/`; - source maps for compiled stack traces; - changing `package.json` `engines` or version; - changing runtime behavior, model routing, hook semantics, tool calls, REPL UX, sessions, or publishing flow. diff --git a/src/agent/context.js b/src/agent/context.ts similarity index 87% rename from src/agent/context.js rename to src/agent/context.ts index 8777c2a..bb42dae 100644 --- a/src/agent/context.js +++ b/src/agent/context.ts @@ -9,9 +9,50 @@ // generic placeholder. This is the operational memory that stops the agent // from re-doing work or thrashing after a compaction. import { nowIso, truncate } from "../util.js"; +import type { ChatCompletion, ChatUsage, ToolCall } from "../types/index.js"; const CHARS_PER_TOKEN = 4; // rough cross-model estimate; budget windows come from the catalog. +export interface AssistantWireToolCall { + id?: string | null; + type?: "function" | string; + function?: { + name?: string | null; + arguments?: string | Record; + }; +} + +export interface ContextMessage { + role: string; + content?: string | null; + reasoning_content?: string | null; + tool_calls?: AssistantWireToolCall[] | null; + [key: string]: unknown; +} + +interface ContextOptions { + model?: string; + budget?: number; + preserveThinking?: boolean; +} + +interface CompactInfo { + compactionCount: number; + droppedTokens: number; +} + +interface CompactOptions { + onCompact?: (info: CompactInfo) => string[] | void | Promise; + force?: boolean; +} + +interface DecisionOverride { + all: boolean; + targets: string[]; +} + +type DecisionOverrideResult = DecisionOverride | null | false; + /** * Build a wire-format assistant message from a provider chat() response, * preserving GLM `reasoning_content` (preserved thinking) verbatim. @@ -27,13 +68,13 @@ const CHARS_PER_TOKEN = 4; // rough cross-model estimate; budget windows come fr * @param {object} resp - { content, reasoning, tool_calls, ... } * @returns {{role:"assistant", content:string, reasoning_content?:string, tool_calls?:Array}} */ -export function assistantMessageFrom(resp) { - const msg = { role: "assistant", content: resp?.content || "" }; +export function assistantMessageFrom(resp: ChatCompletion): ContextMessage { + const msg: ContextMessage = { role: "assistant", content: resp?.content || "" }; if (resp?.reasoning) { msg.reasoning_content = resp.reasoning; } if (resp?.tool_calls?.length) { - msg.tool_calls = resp.tool_calls.map((tc) => ({ + msg.tool_calls = resp.tool_calls.map((tc: ToolCall) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, @@ -43,7 +84,16 @@ export function assistantMessageFrom(resp) { } export class Context { - constructor({ model = "", budget = 24_000, preserveThinking = true } = {}) { + model: string; + budget: number; + preserveThinking: boolean; + messages: ContextMessage[]; + compactionCount: number; + totalTokensIn: number; + totalTokensOut: number; + private decisions: string[]; + + constructor({ model = "", budget = 24_000, preserveThinking = true }: ContextOptions = {}) { this.model = model; this.budget = budget; // soft token budget for the rolling window // Whether reasoning_content counts toward the budget. It only occupies the @@ -58,25 +108,25 @@ export class Context { this.decisions = []; } - setSystem(text) { + setSystem(text: string): void { const existing = this.messages.findIndex((m) => m.role === "system"); const msg = { role: "system", content: text }; if (existing >= 0) this.messages[existing] = msg; else this.messages.unshift(msg); } - push(msg) { + push(msg: ContextMessage): void { this.messages.push(msg); } - addDecision(text) { + addDecision(text: string): void { const decision = normalizeDecision(text); if (!decision) return; this.decisions.push(decision); if (this.decisions.length > 12) this.decisions.shift(); } - getDecisions() { + getDecisions(): string[] { return [...this.decisions]; } @@ -86,7 +136,7 @@ export class Context { * /clear and /resume so a stale `decisions` array does not leak rationale * from a prior session into the next compaction digest. */ - reset() { + reset(): void { this.messages = []; this.decisions = []; this.compactionCount = 0; @@ -98,17 +148,17 @@ export class Context { * but they are scoped to the current compacted conversation and must not * survive REPL /clear or /resume into a fresh transcript. */ - resetToSystemPrompt() { + resetToSystemPrompt(): void { const system = this.messages.find((m) => m.role === "system"); this.messages = system ? [system] : []; this.decisions = []; this.compactionCount = 0; } - estimateTokens() { + estimateTokens(): number { let chars = 0; for (const m of this.messages) { - chars += (m.content?.length || 0); + chars += (typeof m.content === "string" ? m.content.length : 0); // Preserved thinking counts toward the budget only when it's actually on // the wire (this.preserveThinking). Stripping providers never send // reasoning_content, so counting it would force premature compaction. @@ -126,7 +176,7 @@ export class Context { * - the most recent `keepRecent` messages * Fires `onCompact` so the hook engine can react. Returns true if compaction occurred. */ - async maybeCompact({ onCompact, force = false } = {}) { + async maybeCompact({ onCompact, force = false }: CompactOptions = {}): Promise { const tokens = this.estimateTokens(); if (!force && tokens <= this.budget) return false; const keepRecent = 12; @@ -163,7 +213,7 @@ export class Context { suppressedDecisionTargets: tailOverride?.targets || [], }); - const summary = { + const summary: ContextMessage = { role: "system", content: `[Compacted transcript — ${dropped.length} earlier messages digested at ${nowIso()} to fit the GLM context window.]\n\n` + @@ -172,9 +222,9 @@ export class Context { `Continue from the most recent messages. Do not re-ask what was already discussed or re-do work listed in the digest above.`, }; - this.messages = [system, taskMsg, summary, ...tail].filter(Boolean); + this.messages = [system, taskMsg, summary, ...tail].filter((msg): msg is ContextMessage => Boolean(msg)); this.compactionCount += 1; - let injects = []; + let injects: string[] = []; if (onCompact) { const res = await onCompact({ compactionCount: this.compactionCount, droppedTokens: tokens }); if (Array.isArray(res)) injects = res; @@ -202,17 +252,22 @@ export class Context { return true; } - recordUsage(usage) { + recordUsage(usage?: ChatUsage | null): void { if (!usage) return; this.totalTokensIn += usage.prompt_tokens || 0; this.totalTokensOut += usage.completion_tokens || 0; } } -function safeParse(s) { +function safeParse(s: unknown): Record { if (!s) return {}; - if (typeof s !== "string") return s; - try { return JSON.parse(s); } catch { return {}; } + if (typeof s !== "string") return s && typeof s === "object" && !Array.isArray(s) ? s as Record : {}; + try { + const parsed = JSON.parse(s); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } } const DECISION_CUES = [ @@ -357,7 +412,7 @@ const OVERRIDE_CUES = [ RATHER_REPLACEMENT_CUE, ]; -function hasPositiveReplacementCue(content) { +function hasPositiveReplacementCue(content: string): boolean { return ACTUALLY_REPLACEMENT_CUE.test(content) || INSTEAD_REPLACEMENT_CUE.test(content) || CHANGE_TO_CUE.test(content) @@ -368,7 +423,7 @@ function hasPositiveReplacementCue(content) { || DISCARD_DECISION_CUE.test(content); } -function normalizeChoiceTarget(value) { +function normalizeChoiceTarget(value: unknown): string { return String(value || "") .replace(/[`"']/g, "") .replace(/^\s*(?:(?:the|a|an|current|existing|prior|previous|same)\s+)+/i, "") @@ -380,11 +435,11 @@ function normalizeChoiceTarget(value) { .toLowerCase(); } -function escapeRegExp(value) { +function escapeRegExp(value: string): string { return String(value).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); } -function decisionNegatesTarget(decision, target) { +function decisionNegatesTarget(decision: string, target: string): boolean { if (!target) return false; const normalized = normalizeChoiceTarget(decision); const escaped = escapeRegExp(target); @@ -392,7 +447,7 @@ function decisionNegatesTarget(decision, target) { return new RegExp(`(? decisionAffirmsTarget(decision, target)); } -function decisionMatchesTargets(decision, targets = []) { +function decisionMatchesTargets(decision: string, targets: string[] = []): boolean { return targets.some((target) => decisionAffirmsTarget(decision, target)); } -function mergeDecisionOverride(existing, next) { - if (!next) return existing; +function mergeDecisionOverride(existing: DecisionOverride | null, next: DecisionOverride): DecisionOverride { if (!existing) return next.all ? { all: true, targets: [] } : { all: false, targets: [...new Set(next.targets)] }; if (existing.all || next.all) return { all: true, targets: [] }; return { all: false, targets: [...new Set([...existing.targets, ...next.targets])] }; } -function applyDecisionOverride(decisions, override) { +function applyDecisionOverride(decisions: string[], override: DecisionOverrideResult): string[] { if (!override) return [...decisions]; if (override.all) return []; return decisions.filter((decision) => !decisionMatchesTargets(decision, override.targets)); } -function decisionRemovedByOverride(decision, override) { +function decisionRemovedByOverride(decision: string, override: DecisionOverrideResult): boolean { if (!override) return false; if (override.all) return true; return decisionMatchesTargets(decision, override.targets); } -function insteadOfOldChoiceTargets(content, activeDecisions = []) { +function insteadOfOldChoiceTargets(content: string, activeDecisions: string[] = []): string[] { const match = INSTEAD_OF_REPLACEMENT_CUE.exec(content); const replacedTarget = normalizeChoiceTarget(match?.[2]); return decisionsMentionChoice(activeDecisions, replacedTarget) ? [replacedTarget] : []; @@ -515,13 +569,13 @@ function insteadOfOldChoiceTargets(content, activeDecisions = []) { // Leading-order "Instead of Postgres, use SQLite": the old target is in group 1 // (after "instead of"), not group 2. Same eviction logic as insteadOfOldChoiceTargets // but reads the reversed cue's first capture group. -function leadingInsteadOfOldChoiceTargets(content, activeDecisions = []) { +function leadingInsteadOfOldChoiceTargets(content: string, activeDecisions: string[] = []): string[] { const match = LEADING_INSTEAD_OF_REPLACEMENT_CUE.exec(content); const replacedTarget = normalizeChoiceTarget(match?.[1]); return decisionsMentionChoice(activeDecisions, replacedTarget) ? [replacedTarget] : []; } -function ratherThanOldChoiceTargets(content, activeDecisions = []) { +function ratherThanOldChoiceTargets(content: string, activeDecisions: string[] = []): string[] { const match = RATHER_THAN_REPLACEMENT_CUE.exec(content); const replacedTarget = normalizeChoiceTarget(match?.[2]); return decisionsMentionChoice(activeDecisions, replacedTarget) ? [replacedTarget] : []; @@ -530,7 +584,7 @@ function ratherThanOldChoiceTargets(content, activeDecisions = []) { // Leading-order "Rather than Postgres, use SQLite": the old target is in group 1 // (after "rather than"), not group 2. Same eviction logic as ratherThanOldChoiceTargets // but reads the leading cue's first capture group. -function leadingRatherThanOldChoiceTargets(content, activeDecisions = []) { +function leadingRatherThanOldChoiceTargets(content: string, activeDecisions: string[] = []): string[] { const match = LEADING_RATHER_THAN_REPLACEMENT_CUE.exec(content); const replacedTarget = normalizeChoiceTarget(match?.[1]); return decisionsMentionChoice(activeDecisions, replacedTarget) ? [replacedTarget] : []; @@ -542,7 +596,7 @@ function leadingRatherThanOldChoiceTargets(content, activeDecisions = []) { // group 1 = rejected target, group 2 = kept target. Only evict the rejected target // when it is an active decision, so the preserve path still works when the rejected // choice was never persisted. -function ratherThanPreserveRejectedTargets(content, activeDecisions = []) { +function ratherThanPreserveRejectedTargets(content: string, activeDecisions: string[] = []): string[] { const match = RATHER_THAN_PRESERVE_CUE.exec(content); const rejectedTarget = normalizeChoiceTarget(match?.[1]); return decisionsMentionChoice(activeDecisions, rejectedTarget) ? [rejectedTarget] : []; @@ -554,7 +608,7 @@ function ratherThanPreserveRejectedTargets(content, activeDecisions = []) { // to use instead, and the prior decision's affirmed choice is the rejected one. // This mirrors the targeted eviction path so the superseded decision is evicted // precisely without clearing unrelated decisions. -function bareUseReplacementTargets(content, activeDecisions = []) { +function bareUseReplacementTargets(content: string, activeDecisions: string[] = []): string[] { // Skip when an explicit qualifier is present: those are handled by the // instead-of / rather-than / actually / second-thought / nevermind / discard // paths above, which name the old target explicitly. The bare path only @@ -577,7 +631,7 @@ function bareUseReplacementTargets(content, activeDecisions = []) { // technology choice is active, so the correction is unambiguous. When multiple // unrelated choices exist, the user must name the old target explicitly // (instead-of / rather-than) or the turn falls through to the neutral path. - const candidateTargets = []; + const candidateTargets: string[] = []; for (const decision of activeDecisions) { const choiceMatch = USE_CHOICE_FROM_DECISION_CUE.exec(decision); if (!choiceMatch) continue; @@ -602,11 +656,11 @@ function bareUseReplacementTargets(content, activeDecisions = []) { return candidateTargets; } -function isNegatedReplacementOverride(content, activeDecisions = []) { +function isNegatedReplacementOverride(content: string, activeDecisions: string[] = []): boolean { return negatedReplacementOverrideTargets(content, activeDecisions).length > 0; } -function negatedReplacementOverrideTargets(content, activeDecisions = []) { +function negatedReplacementOverrideTargets(content: string, activeDecisions: string[] = []): string[] { const negatedTarget = firstChoiceTarget(content, NEGATED_REPLACEMENT_TARGET_CUES); const preservedTarget = firstChoiceTarget(content, PRESERVE_TARGET_CUES); if (!decisionsMentionChoice(activeDecisions, negatedTarget)) return []; @@ -615,15 +669,15 @@ function negatedReplacementOverrideTargets(content, activeDecisions = []) { return [negatedTarget]; } -function isKeepGoingTarget(target) { +function isKeepGoingTarget(target: string): boolean { return /^(?:going|working|on|at it)$/i.test(target); } -function isNegatedReplaceOnlyTurn(content) { +function isNegatedReplaceOnlyTurn(content: string): boolean { return /\b(?:do not|don't|dont)\s+replace\b|\b(?:no|not|without)\s+replacement\b/i.test(content); } -function targetedOverrideTargets(content, activeDecisions = []) { +function targetedOverrideTargets(content: string, activeDecisions: string[] = []): string[] { return [...new Set([ ...negatedReplacementOverrideTargets(content, activeDecisions), ...insteadOfOldChoiceTargets(content, activeDecisions), @@ -635,7 +689,7 @@ function targetedOverrideTargets(content, activeDecisions = []) { ])]; } -function isPreserveChoiceTurn(content, activeDecisions = []) { +function isPreserveChoiceTurn(content: string, activeDecisions: string[] = []): boolean { // "No change to ..." and "do not change to ..." preserve the current choice; // negated replacement/use wording does too when paired with explicit keep/retain // language ("Don't replace Postgres; keep it"). If the negated target matches @@ -660,7 +714,7 @@ function isPreserveChoiceTurn(content, activeDecisions = []) { || (PRESERVE_CHOICE_CUE.test(content) && !hasPositiveReplacementCue(content)); } -function decisionOverrideForTurn(m, activeDecisions = []) { +function decisionOverrideForTurn(m: ContextMessage, activeDecisions: string[] = []): DecisionOverrideResult { if (m.role !== "user" || typeof m.content !== "string") return false; // Collapse whitespace so multiline/pasted user turns like "Actually,\nuse // SQLite." match the same override cues as their single-line equivalents. @@ -677,8 +731,8 @@ function decisionOverrideForTurn(m, activeDecisions = []) { return null; } -function collectDecisionOverrides(messages, activeDecisions = []) { - let override = null; +function collectDecisionOverrides(messages: ContextMessage[], activeDecisions: string[] = []): DecisionOverride | null { + let override: DecisionOverride | null = null; let visibleDecisions = [...activeDecisions]; for (const m of messages) { const turnOverride = decisionOverrideForTurn(m, visibleDecisions); @@ -696,11 +750,11 @@ function collectDecisionOverrides(messages, activeDecisions = []) { return override; } -function isDecisionSentence(text) { +function isDecisionSentence(text: string): boolean { return DECISION_CUES.some((cue) => cue.test(text)); } -function normalizeDecision(text) { +function normalizeDecision(text: unknown): string { const s = String(text || "").replace(/\s+/g, " ").trim(); if (s.length <= 200) return s; // Keep decisions on a single line: the shared truncate() appends a newline @@ -709,7 +763,7 @@ function normalizeDecision(text) { return s.slice(0, 197) + "…"; } -function extractSentences(text) { +function extractSentences(text: unknown): string[] { const normalized = String(text || "").replace(/\s+/g, " ").trim(); if (!normalized) return []; // Split on [.!?] only at a real sentence boundary: a period directly followed @@ -719,11 +773,11 @@ function extractSentences(text) { return normalized.match(/.+?(?:[.!?](?=\s|$)|$)(?:\s|$)*/gsu) || [normalized]; } -function extractDecisions(dropped, existingDecisions = []) { +function extractDecisions(dropped: ContextMessage[], existingDecisions: string[] = []): { decisions: string[]; override: DecisionOverride | null } { let activeExistingDecisions = [...existingDecisions]; - let decisions = []; - const seen = new Set(); - let override = null; + let decisions: string[] = []; + const seen = new Set(); + let override: DecisionOverride | null = null; for (const m of dropped) { // A user turn that reverses an earlier assistant decision. Once an override @@ -763,15 +817,19 @@ function extractDecisions(dropped, existingDecisions = []) { * retains operational memory (what it already did) after compaction — without * spending an extra LLM call on summarization. */ -function buildDigest(dropped, prevDecisions = [], { suppressDecisionNotes = false, suppressedDecisionTargets = [] } = {}) { - const filesWritten = new Set(); - const filesPatched = new Set(); - const commands = []; - const errors = []; - let notes = []; +function buildDigest( + dropped: ContextMessage[], + prevDecisions: string[] = [], + { suppressDecisionNotes = false, suppressedDecisionTargets = [] }: { suppressDecisionNotes?: boolean; suppressedDecisionTargets?: string[] } = {}, +): string { + const filesWritten = new Set(); + const filesPatched = new Set(); + const commands: string[] = []; + const errors: string[] = []; + let notes: Array<{ text: string; isDecision: boolean }> = []; let notesLength = 0; - const appendNote = (text) => { + const appendNote = (text: string): void => { if (notesLength >= 800) return; const isDecision = isDecisionSentence(text); if (suppressDecisionNotes && isDecision) return; @@ -796,9 +854,9 @@ function buildDigest(dropped, prevDecisions = [], { suppressDecisionNotes = fals for (const tc of m.tool_calls) { const fn = tc.function || {}; const args = safeParse(fn.arguments); - if (fn.name === "write_file" && args.path) filesWritten.add(args.path); - else if (fn.name === "patch_file" && args.path) filesPatched.add(args.path); - else if (fn.name === "run_shell" && args.command) commands.push(truncate(args.command, 80)); + if (fn.name === "write_file" && args.path) filesWritten.add(String(args.path)); + else if (fn.name === "patch_file" && args.path) filesPatched.add(String(args.path)); + else if (fn.name === "run_shell" && args.command) commands.push(truncate(String(args.command), 80)); } } if (typeof m.content === "string" && m.content.trim() && notesLength < 800) { @@ -817,7 +875,7 @@ function buildDigest(dropped, prevDecisions = [], { suppressDecisionNotes = fals } } - const parts = []; + const parts: string[] = []; if (filesWritten.size) parts.push(`Files created: ${[...filesWritten].join(", ")}`); if (filesPatched.size) parts.push(`Files modified: ${[...filesPatched].join(", ")}`); if (commands.length) parts.push(`Commands run: ${commands.slice(-8).join(" | ")}`); diff --git a/src/agent/deadline.js b/src/agent/deadline.ts similarity index 63% rename from src/agent/deadline.js rename to src/agent/deadline.ts index b0a4976..1ba276b 100644 --- a/src/agent/deadline.js +++ b/src/agent/deadline.ts @@ -1,47 +1,71 @@ // Shared wall-clock deadline and AbortSignal helpers for headless runs. export class DeadlineExceededError extends Error { + readonly code = "LAZYGLM_TIMEOUT"; + constructor(message = "LazyGLM run timed out") { super(message); this.name = "DeadlineExceededError"; - this.code = "LAZYGLM_TIMEOUT"; } } export class RequestTimeoutError extends Error { + readonly code = "LAZYGLM_REQUEST_TIMEOUT"; + constructor(message = "GLM request timed out") { super(message); this.name = "RequestTimeoutError"; - this.code = "LAZYGLM_REQUEST_TIMEOUT"; } } -export function isDeadlineError(err) { - return err?.code === "LAZYGLM_TIMEOUT" || err?.name === "DeadlineExceededError"; +type AbortLike = AbortSignal | null | undefined; +type AbortListener = () => void; + +export interface ComposedAbortSignal { + signal?: AbortSignal; + cancel(): void; +} + +export interface Deadline { + signal?: AbortSignal; + deadlineAt: number | null; + timeoutMs: number; + disabled: boolean; + remainingMs(): number; + throwIfExpired(): void; + cancel(): void; +} + +function errorField(err: unknown, field: "code" | "name"): unknown { + return err && typeof err === "object" ? (err as Record)[field] : undefined; +} + +export function isDeadlineError(err: unknown): boolean { + return errorField(err, "code") === "LAZYGLM_TIMEOUT" || errorField(err, "name") === "DeadlineExceededError"; } -export function isRequestTimeoutError(err) { - return err?.code === "LAZYGLM_REQUEST_TIMEOUT" || err?.name === "RequestTimeoutError"; +export function isRequestTimeoutError(err: unknown): boolean { + return errorField(err, "code") === "LAZYGLM_REQUEST_TIMEOUT" || errorField(err, "name") === "RequestTimeoutError"; } -export function abortReason(signal, fallback = new Error("Operation aborted")) { +export function abortReason(signal?: AbortLike, fallback: Error = new Error("Operation aborted")): Error { const reason = signal?.reason; if (reason instanceof Error) return reason; if (reason) return new Error(String(reason)); return fallback; } -export function throwIfAborted(signal) { +export function throwIfAborted(signal?: AbortLike): void { if (signal?.aborted) throw abortReason(signal); } -export function composeAbortSignals(signals = []) { - const active = signals.filter(Boolean); +export function composeAbortSignals(signals: AbortLike[] = []): ComposedAbortSignal { + const active = signals.filter((signal): signal is AbortSignal => Boolean(signal)); if (!active.length) return { signal: undefined, cancel() {} }; const controller = new AbortController(); - const listeners = []; - const abortFrom = (signal) => { + const listeners: Array<[AbortSignal, AbortListener]> = []; + const abortFrom = (signal: AbortSignal): void => { if (!controller.signal.aborted) controller.abort(abortReason(signal)); }; @@ -50,7 +74,7 @@ export function composeAbortSignals(signals = []) { abortFrom(signal); break; } - const listener = () => abortFrom(signal); + const listener: AbortListener = () => abortFrom(signal); signal.addEventListener("abort", listener, { once: true }); listeners.push([signal, listener]); } @@ -63,7 +87,10 @@ export function composeAbortSignals(signals = []) { }; } -export function createDeadline(timeoutMs, { signal, message } = {}) { +export function createDeadline( + timeoutMs: number | string, + { signal, message }: { signal?: AbortLike; message?: string } = {}, +): Deadline { const ms = Number(timeoutMs); if (!Number.isFinite(ms) || ms <= 0) { const composed = composeAbortSignals([signal]); @@ -108,7 +135,7 @@ export function createDeadline(timeoutMs, { signal, message } = {}) { }; } -export function boundedTimeoutMs(preferredMs, deadline) { +export function boundedTimeoutMs(preferredMs: number | string, deadline?: { remainingMs?: () => number } | null): number { const preferred = Number(preferredMs); const remaining = deadline?.remainingMs?.() ?? Infinity; if (!Number.isFinite(remaining)) return preferred; @@ -116,15 +143,15 @@ export function boundedTimeoutMs(preferredMs, deadline) { return Math.max(1, Math.min(preferred, remaining)); } -export function abortableSleep(ms, signal) { +export function abortableSleep(ms: number | string, signal?: AbortLike): Promise { const delay = Math.max(0, Number(ms) || 0); throwIfAborted(signal); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(done, delay); const onAbort = () => done(abortReason(signal)); if (signal) signal.addEventListener("abort", onAbort, { once: true }); - function done(err) { + function done(err?: Error): void { clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); if (err) reject(err); @@ -133,10 +160,10 @@ export function abortableSleep(ms, signal) { }); } -export function withAbort(promise, signal) { +export function withAbort(promise: PromiseLike | T, signal?: AbortLike): Promise { throwIfAborted(signal); - if (!signal) return promise; - return new Promise((resolve, reject) => { + if (!signal) return Promise.resolve(promise); + return new Promise((resolve, reject) => { const onAbort = () => { cleanup(); reject(abortReason(signal)); @@ -150,11 +177,11 @@ export function withAbort(promise, signal) { }); } -export function requestTimeoutError(timeoutMs) { +export function requestTimeoutError(timeoutMs: number | string): RequestTimeoutError { return new RequestTimeoutError(`GLM request timed out after ${timeoutMs}ms.`); } -function formatDuration(ms) { +function formatDuration(ms: number): string { const seconds = ms / 1000; return Number.isInteger(seconds) ? `${seconds}s` : `${seconds.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}s`; } diff --git a/src/agent/thinking.js b/src/agent/thinking.ts similarity index 77% rename from src/agent/thinking.js rename to src/agent/thinking.ts index 3c44ac2..4f371a3 100644 --- a/src/agent/thinking.js +++ b/src/agent/thinking.ts @@ -1,5 +1,3 @@ -// @ts-check - // z.ai documents thinking mode as a first-class chat-completions control: // https://docs.z.ai/guides/capabilities/thinking-mode // @@ -8,11 +6,15 @@ // the first provider slice binary: low disables thinking; medium/high/max enable // it. It does not add an "off" ReasoningEffort value. -/** - * @typedef {import("../types/index.js").Provider} Provider - * @typedef {import("../types/index.js").ReasoningEffort} ReasoningEffort - * @typedef {{ type: "disabled" } | { type: "enabled", clear_thinking?: false }} ThinkingControl - */ +import type { Provider, ReasoningEffort } from "../types/index.js"; + +export type ThinkingControl = { type: "disabled" } | { type: "enabled"; clear_thinking?: false }; + +interface ThinkingControlOptions { + provider?: Provider | null; + reasoningEffort?: ReasoningEffort | null; + preserveThinking?: boolean; +} /** * Convert LazyGLM's active route effort into z.ai's request-level thinking @@ -25,10 +27,14 @@ * @param {boolean} [options.preserveThinking] * @returns {ThinkingControl | null} */ -export function thinkingControlForRequest({ provider, reasoningEffort, preserveThinking = false } = {}) { +export function thinkingControlForRequest({ + provider, + reasoningEffort, + preserveThinking = false, +}: ThinkingControlOptions = {}): ThinkingControl | null { if (provider !== "zai") return null; if (reasoningEffort === "low") return { type: "disabled" }; - const control = /** @type {{ type: "enabled", clear_thinking?: false }} */ ({ type: "enabled" }); + const control: ThinkingControl = { type: "enabled" }; if (preserveThinking) control.clear_thinking = false; return control; } @@ -49,7 +55,7 @@ const REASONING_EFFORT_FLOOR = [5, 2]; * @param {string} modelId - canonical model name (e.g. "glm-5.2", "glm-4.7") * @returns {boolean} */ -export function supportsReasoningEffort(modelId) { +export function supportsReasoningEffort(modelId: string | null | undefined): boolean { const match = String(modelId || "").match(/(\d+)\.(\d+)/); if (!match) return false; const [major, minor] = [Number(match[1]), Number(match[2])]; diff --git a/src/banner.js b/src/banner.ts similarity index 89% rename from src/banner.js rename to src/banner.ts index 970bba0..4d65fba 100644 --- a/src/banner.js +++ b/src/banner.ts @@ -50,13 +50,36 @@ const BOX = { const PANEL_WIDTH = WORDMARK_WIDTH - 4; // panel total width aligns with the wordmark const LABEL_WIDTH = 9; // left-aligned label field (model/provider/cwd/...) +export interface BannerGitInfo { + isRepo: boolean; + branch?: string | null; + root?: string | null; +} + +export interface BannerSessionInfo { + id?: string | null; +} + +export interface BannerOptions { + model?: string | null; + provider?: string | null; + cwd?: string | null; + git?: BannerGitInfo | null; + session?: BannerSessionInfo | null; + yolo?: boolean; + tier?: string | null; + tierReason?: string | null; + isTTY?: boolean; +} + // Visible-width helpers so styled rows (with raw ANSI codes) still pad to the // panel width. Escape sequences render as 0 cols but inflate String.length, and // some glyphs (⚡, emoji, CJK) render as 2 cols — both must be accounted for or // the panel's right border misaligns. This is a minimal wcwidth approximation: // emoji / dingbat / CJK ranges count as 2, everything else as 1. const ANSI_RE = /\x1b\[[0-9;]*m/g; -function isWide(cp) { +function isWide(cp: number | undefined): boolean { + if (cp == null) return false; return ( (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || @@ -69,18 +92,18 @@ function isWide(cp) { (cp >= 0x2600 && cp <= 0x27bf) // misc symbols & dingbats (incl ⚡ U+26A1) ); } -function renderedWidth(s) { +function renderedWidth(s: string): number { let w = 0; for (const ch of s.replace(ANSI_RE, "")) w += isWide(ch.codePointAt(0)) ? 2 : 1; return w; } -function padVisible(s, width) { +function padVisible(s: string, width: number): string { const v = renderedWidth(s); return v >= width ? s : s + " ".repeat(width - v); } /** Trailing-truncate `s` to a max RENDERED width, keeping the leading part + `…`. */ -function truncateToWidth(s, maxW) { +function truncateToWidth(s: unknown, maxW: number): string { const str = String(s ?? ""); if (renderedWidth(str) <= maxW) return str; if (maxW <= 1) return "…"; @@ -95,7 +118,7 @@ function truncateToWidth(s, maxW) { return acc + "…"; } -function centerVisible(s, width) { +function centerVisible(s: string, width: number): string { const value = truncateToWidth(s, width); const v = renderedWidth(value); if (v >= width) return value; @@ -104,12 +127,12 @@ function centerVisible(s, width) { return " ".repeat(left) + value + " ".repeat(right); } -function cleanSegment(value) { +function cleanSegment(value: unknown): string { return String(value ?? "").replace(/[|\r\n]+/g, " ").replace(/\s+/g, " ").trim(); } /** One `label value` row, padded to exactly `width` rendered columns. */ -function fieldRow(label, value, width) { +function fieldRow(label: string, value: unknown, width: number): string { const labelField = label ? String(label).padEnd(LABEL_WIDTH) : ""; const valueField = truncateToWidth(value, width - renderedWidth(labelField)); return padVisible(labelField + valueField, width); @@ -140,7 +163,7 @@ export function renderBanner({ tier, tierReason, isTTY, -} = {}) { +}: BannerOptions = {}): string { const m = model ?? "?"; const p = provider ?? "?"; const dir = cwd ?? ""; @@ -155,7 +178,7 @@ export function renderBanner({ return `${parts.join(" | ")}\n`; } - const out = []; + const out: string[] = []; out.push(""); // leading blank line for breathing room // Wordmark with per-row cyan->gray gradient. @@ -167,7 +190,7 @@ export function renderBanner({ // Info panel. const b = BOX.tty; - const rows = []; + const rows: string[] = []; rows.push(fieldRow("model", m, PANEL_WIDTH)); if (tier) rows.push(fieldRow("tier", tier, PANEL_WIDTH)); if (tierReason) rows.push(fieldRow("guidance", tierReason, PANEL_WIDTH)); diff --git a/src/cli-output.js b/src/cli-output.ts similarity index 56% rename from src/cli-output.js rename to src/cli-output.ts index 204fed0..956730c 100644 --- a/src/cli-output.js +++ b/src/cli-output.ts @@ -1,23 +1,56 @@ import { truncate } from "./util.js"; +import type { Writable } from "node:stream"; const GRAY = "\x1b[90m"; const DIM = "\x1b[2m"; const YELLOW = "\x1b[33m"; const RESET = "\x1b[0m"; -export function createRunEventPrinter({ stdout = process.stdout, stderr = process.stderr, isTTY = stdout?.isTTY === true } = {}) { +type OutputStream = Pick & { isTTY?: boolean }; + +type RunPrinterEvent = Record & { + type?: string; + text?: string; + content?: string | null; + input?: string | null; + result?: string | null; + summary?: string | null; + reason?: string | null; + message?: string | null; + reasons?: unknown[]; +}; + +export interface RunEventPrinterOptions { + stdout?: OutputStream; + stderr?: OutputStream; + isTTY?: boolean; +} + +function textField(value: unknown): string { + return value == null ? "" : String(value); +} + +function numberField(value: unknown): number { + return Number(value) || 0; +} + +export function createRunEventPrinter({ + stdout = process.stdout, + stderr = process.stderr, + isTTY = stdout?.isTTY === true, +}: RunEventPrinterOptions = {}): (event: RunPrinterEvent) => void { const tty = isTTY === true; let streamOpen = false; - let streamMode = null; // "text" | "reasoning" + let streamMode: "text" | "reasoning" | null = null; let streamedText = false; // suppresses the assistant_text echo when deltas already showed it - const ansi = (code) => (tty ? code : ""); - const writeOut = (text) => stdout.write(String(text)); - const writeErr = (text) => stderr.write(String(text)); - const lineOut = (text = "") => writeOut(`${text}\n`); - const lineErr = (text = "") => writeErr(`${text}\n`); + const ansi = (code: string): string => (tty ? code : ""); + const writeOut = (text: unknown): boolean => stdout.write(String(text)); + const writeErr = (text: unknown): boolean => stderr.write(String(text)); + const lineOut = (text = ""): boolean => writeOut(`${text}\n`); + const lineErr = (text = ""): boolean => writeErr(`${text}\n`); - function closeStream() { + function closeStream(): void { if (streamOpen) { writeOut(`${ansi(RESET)}\n`); streamOpen = false; @@ -25,12 +58,12 @@ export function createRunEventPrinter({ stdout = process.stdout, stderr = proces } } - function printEvent(ev) { + function printEvent(ev: RunPrinterEvent): void { switch (ev.type) { case "start": lineOut(`\n🚀 LazyGLM session ${ev.sessionId} | model: ${ev.model} | provider: ${ev.provider || "?"} | role: ${ev.role || "default"}`); lineOut(` cwd: ${ev.cwd}`); - lineOut(` task: ${truncate(ev.task, 200)}\n`); + lineOut(` task: ${truncate(textField(ev.task), 200)}\n`); break; case "reasoning_delta": // Reasoning streams first (GLM-5.2 thinks before answering). Show it dimmed @@ -44,7 +77,7 @@ export function createRunEventPrinter({ stdout = process.stdout, stderr = proces writeOut(`${ansi(RESET)}\n${ansi(GRAY)}✶ `); streamMode = "reasoning"; } - writeOut(ev.text); + writeOut(textField(ev.text)); break; case "assistant_delta": if (streamOpen && streamMode === "reasoning") { @@ -56,7 +89,7 @@ export function createRunEventPrinter({ stdout = process.stdout, stderr = proces streamOpen = true; streamMode = "text"; streamedText = true; - writeOut(ev.text); + writeOut(textField(ev.text)); break; case "assistant_text": // Close any open stream line first. @@ -78,10 +111,10 @@ export function createRunEventPrinter({ stdout = process.stdout, stderr = proces break; } case "tool_result": - lineOut(` ↳ ${truncate(ev.result, 400)}`); + lineOut(` ↳ ${truncate(textField(ev.result), 400)}`); break; case "blocked": - lineOut(`⛔ blocked ${ev.tool}: ${ev.reasons.join("; ")}`); + lineOut(`⛔ blocked ${ev.tool}: ${(Array.isArray(ev.reasons) ? ev.reasons : []).map(textField).join("; ")}`); break; case "retry": closeStream(); @@ -94,16 +127,21 @@ export function createRunEventPrinter({ stdout = process.stdout, stderr = proces case "usage": { // Surface reasoning-token spend — the GLM-native cost signal. Only print // when reasoning tokens are non-zero to avoid noise on non-reasoning tiers. - const cum = ev.cumulative || {}; - const turnReasoning = ev.usage?.completion_tokens_details?.reasoning_tokens || ev.usage?.reasoning_tokens || 0; - if (turnReasoning > 0 || cum.reasoning > 0) { - lineOut(`${ansi(GRAY)} 🧠 reasoning: +${turnReasoning} (cum ${cum.reasoning || 0}) | tokens in/out: ${cum.prompt || 0}/${cum.completion || 0}${ansi(RESET)}`); + const cum = ev.cumulative && typeof ev.cumulative === "object" ? ev.cumulative as Record : {}; + const usage = ev.usage && typeof ev.usage === "object" ? ev.usage as Record : {}; + const details = usage.completion_tokens_details && typeof usage.completion_tokens_details === "object" + ? usage.completion_tokens_details as Record + : {}; + const turnReasoning = numberField(details.reasoning_tokens || usage.reasoning_tokens); + const cumulativeReasoning = numberField(cum.reasoning); + if (turnReasoning > 0 || cumulativeReasoning > 0) { + lineOut(`${ansi(GRAY)} 🧠 reasoning: +${turnReasoning} (cum ${cumulativeReasoning}) | tokens in/out: ${numberField(cum.prompt)}/${numberField(cum.completion)}${ansi(RESET)}`); } break; } case "finish": closeStream(); - lineOut(`\n✅ FINISH: ${truncate(ev.summary, 1500)}\n`); + lineOut(`\n✅ FINISH: ${truncate(textField(ev.summary), 1500)}\n`); break; case "compact": lineOut(` (context compacted — #${ev.compactionCount})`); @@ -113,11 +151,11 @@ export function createRunEventPrinter({ stdout = process.stdout, stderr = proces lineOut(`\n🔁 ULTRAWORK iteration ${ev.iteration}/${ev.max}`); break; case "ultrawork_verify": - lineOut(` verify: ${ev.pass ? "PASS ✅" : "FAIL ❌"} — ${truncate(ev.reason, 300)}`); + lineOut(` verify: ${ev.pass ? "PASS ✅" : "FAIL ❌"} — ${truncate(textField(ev.reason), 300)}`); break; case "error": closeStream(); - lineErr(`❌ error: ${ev.message}`); + lineErr(`❌ error: ${textField(ev.message)}`); break; default: break; diff --git a/src/cli.js b/src/cli.ts similarity index 76% rename from src/cli.js rename to src/cli.ts index 4df67bb..083cf15 100644 --- a/src/cli.js +++ b/src/cli.ts @@ -14,10 +14,83 @@ import { createRunEventPrinter } from "./cli-output.js"; import { createDeadline, isDeadlineError } from "./agent/deadline.js"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import type { + HookEventName, + HookInput, + PermissionMode, + RoleName, + RunAgentResult, + ToolExecutionRecord, +} from "./types/index.js"; +import type { VerifyFinishResult } from "./ulw.js"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -function usage() { +type FlagValue = string | boolean | undefined; +type FlagMap = Record; + +interface ParsedFlags { + flags: FlagMap; + positional: string[]; +} + +interface ParseResult { + value?: T; + error?: string; +} + +interface VerificationSummary { + pass: boolean; + reason: string; +} + +interface StructuredOutput { + ok: boolean; + result: string | null; + finishReason: string; + toolCalls: Array<{ name: string; turn: number; status: string }>; + cost: { + tokens: number; + promptTokens: number; + completionTokens: number; + reasoningTokens: number; + }; + session: { + id: string; + transcriptPath: string; + turns: number; + compactions: number; + } | null; + verification?: VerificationSummary; + error?: { message: string }; +} + +interface RunResultOverrides { + finishReason?: string; + verification?: VerificationSummary; + errorMessage?: string | null; +} + +interface UltraworkResultShape { + verified?: boolean; + iterations?: number; + verdict?: Pick | null; + history?: RunAgentResult[]; + finishReason?: string; + finishSummary?: string | null; + errorMessage?: string | null; +} + +function stringFlag(flags: FlagMap, key: string): string | undefined { + const value = flags[key]; + return typeof value === "string" ? value : undefined; +} + +function toErrorMessage(err: unknown): string { + return err && typeof err === "object" && "message" in err ? String((err as { message?: unknown }).message) : String(err); +} + +function usage(): string { return `LazyGLM — the GLM-native agent harness. Usage: @@ -64,9 +137,9 @@ Environment: `; } -function parseFlags(argv) { - const flags = {}; - const positional = []; +function parseFlags(argv: string[]): ParsedFlags { + const flags: FlagMap = {}; + const positional: string[] = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a.startsWith("--")) { @@ -81,7 +154,7 @@ function parseFlags(argv) { return { flags, positional }; } -export async function main(argv) { +export async function main(argv: string[]): Promise { const cmd = argv[0]; const rest = argv.slice(1); @@ -90,8 +163,8 @@ export async function main(argv) { return 0; } if (cmd === "--version" || cmd === "-v") { - const pkg = await readJson(join(ROOT, "package.json"), {}); - console.log(`lazyglm ${pkg.version || "0.0.0"}`); + const pkg = await readJson<{ version?: string }>(join(ROOT, "package.json"), {}); + console.log(`lazyglm ${pkg?.version || "0.0.0"}`); return 0; } @@ -107,15 +180,22 @@ export async function main(argv) { } const { launchREPL } = await import("./repl.js"); return launchREPL({ - cwd: flags.cwd ? resolve(flags.cwd) : process.cwd(), - flags: { continue: !!flags.continue, yolo: !!flags.yolo, model: flags.model, provider: flags.provider, role: flags.role, contextBudget: contextBudget.value }, + cwd: flags.cwd ? resolve(flags.cwd as string) : process.cwd(), + flags: { + continue: !!flags.continue, + yolo: !!flags.yolo, + model: stringFlag(flags, "model"), + provider: stringFlag(flags, "provider"), + role: stringFlag(flags, "role"), + contextBudget: contextBudget.value, + }, }); } switch (cmd) { case "install": { const { flags } = parseFlags(rest); - const res = await install({ cwd: flags.cwd ? resolve(flags.cwd) : process.cwd(), force: !!flags.force }); + const res = await install({ cwd: flags.cwd ? resolve(flags.cwd as string) : process.cwd(), force: !!flags.force }); console.log(`LazyGLM installed in ${res.cwd}`); for (const c of res.created) console.log(` + ${c}`); if (!res.git.isRepo) console.log(" (not a git repo — `git init` recommended)"); @@ -124,7 +204,7 @@ export async function main(argv) { } case "uninstall": { const { flags } = parseFlags(rest); - const res = await uninstall({ cwd: flags.cwd ? resolve(flags.cwd) : process.cwd() }); + const res = await uninstall({ cwd: flags.cwd ? resolve(flags.cwd as string) : process.cwd() }); console.log(`Removed from ${res.cwd}:`); for (const r of res.removed) console.log(` - ${r}`); if (res.preserved?.length) { @@ -135,7 +215,7 @@ export async function main(argv) { } case "doctor": { const { flags } = parseFlags(rest); - const res = await doctor({ cwd: flags.cwd ? resolve(flags.cwd) : process.cwd() }); + const res = await doctor({ cwd: flags.cwd ? resolve(flags.cwd as string) : process.cwd() }); console.log(`LazyGLM doctor — ${res.cwd}`); console.log(`Provider: ${res.provider.baseURL} | default model: ${res.provider.modelId}\n`); for (const c of res.checks) { @@ -162,7 +242,7 @@ export async function main(argv) { console.log(`Models at ${cfg.baseURL} (${cfg.provider}, ${models.length}):`); for (const m of models) console.log(` ${m}`); } catch (e) { - console.error(`Cannot list models: ${e.message}`); + console.error(`Cannot list models: ${toErrorMessage(e)}`); return 1; } return 0; @@ -187,23 +267,29 @@ export async function main(argv) { // bridge: read stdin JSON, fire one event with all plugins const event = rest[0]; const data = await readStdin(); - let input; - try { input = JSON.parse(data); } catch { input = {}; } - const cwd = input.cwd ? resolve(input.cwd) : process.cwd(); + let input: Record; + try { + const parsed = JSON.parse(data); + input = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + input = {}; + } + const cwd = typeof input.cwd === "string" ? resolve(input.cwd) : process.cwd(); const engine = new HookEngine({ cwd }); for (const p of loadPlugins()) engine.register(p); - engine.setMeta({ model: input.model || "glm-4.7-flash" }); + engine.setMeta({ model: typeof input.model === "string" ? input.model : "glm-4.7-flash" }); // synthesize a single-fire: bypass fire's turn bookkeeping by calling handlers directly const api = engine.api(); - const out = []; + const out: Array> = []; + const hookEvent = event as HookEventName; for (const plugin of engine.plugins) { - const handler = plugin.hooks?.[event]; + const handler = plugin.hooks?.[hookEvent]; if (typeof handler !== "function") continue; try { - const result = await handler({ ...input, hook_event_name: event, session_id: engine.sessionId, cwd }, api); + const result = await handler({ ...input, hook_event_name: hookEvent, session_id: engine.sessionId, cwd } as HookInput, api); if (result) out.push({ plugin: plugin.name, ...result }); } catch (e) { - out.push({ plugin: plugin.name, error: e.message }); + out.push({ plugin: plugin.name, error: toErrorMessage(e) }); } } const block = out.find((o) => o.decision === "block"); @@ -223,7 +309,7 @@ export async function main(argv) { return 1; } - const task = positional.join(" ").trim() || flags.task; + const task = positional.join(" ").trim() || stringFlag(flags, "task"); if (!task) return runUsageError("usage: lazyglm run \"\"", jsonMode); const maxTurns = parsePositiveIntegerFlag(flags["max-turns"], 80, "--max-turns"); @@ -235,20 +321,21 @@ export async function main(argv) { const contextBudget = parsePositiveIntegerFlag(flags["context-budget"], undefined, "--context-budget"); if (contextBudget.error) return runUsageError(contextBudget.error, jsonMode); - const cwd = flags.cwd ? resolve(flags.cwd) : process.cwd(); - const model = flags.model; - const role = flags.role; + const cwd = flags.cwd ? resolve(flags.cwd as string) : process.cwd(); + const model = stringFlag(flags, "model"); + const role = stringFlag(flags, "role") as RoleName | undefined; const ultrawork = !!flags.ultrawork || /\$ulw-loop\b/i.test(task); - const permissionMode = flags.yolo ? "yolo" : "auto"; + const permissionMode: PermissionMode = flags.yolo ? "yolo" : "auto"; const printEvent = jsonMode ? () => {} : createRunEventPrinter({ stdout: process.stdout, stderr: process.stderr, isTTY: process.stdout.isTTY === true && !flags["no-color"] }); - const deadline = createDeadline(timeoutSeconds.value * 1000, { message: `LazyGLM run timed out after ${formatSeconds(timeoutSeconds.value)}.` }); + const timeoutValue = timeoutSeconds.value ?? 600; + const deadline = createDeadline(timeoutValue * 1000, { message: `LazyGLM run timed out after ${formatSeconds(timeoutValue)}.` }); try { if (ultrawork) { - const completionPromise = flags["completion-promise"] || "the task is fully implemented, builds cleanly, and passes verification."; - const verifyCommand = flags.verify; + const completionPromise = stringFlag(flags, "completion-promise") || "the task is fully implemented, builds cleanly, and passes verification."; + const verifyCommand = stringFlag(flags, "verify"); const maxIterations = parsePositiveIntegerFlag(flags["max-iterations"], 3, "--max-iterations"); if (maxIterations.error) return runUsageError(maxIterations.error, jsonMode); const res = await runUltrawork({ @@ -298,13 +385,13 @@ export async function main(argv) { let errorMessage = res.errorMessage; if (res.finished && flags.verify) { try { - const verdict = await verifyFinish({ summary: res.finishSummary, cwd, verifyCommand: flags.verify, filesWritten: res.filesWritten, deadline }); + const verdict = await verifyFinish({ summary: res.finishSummary, cwd, verifyCommand: stringFlag(flags, "verify"), filesWritten: res.filesWritten, deadline }); verification = { pass: verdict.pass, reason: verdict.reason }; if (!verdict.pass) finishReason = "verify_failed"; } catch (err) { if (isDeadlineError(err) || deadline.signal?.aborted) { finishReason = "timeout"; - errorMessage = err?.message || "timeout"; + errorMessage = toErrorMessage(err) || "timeout"; verification = { pass: false, reason: errorMessage }; } else { throw err; @@ -323,7 +410,7 @@ export async function main(argv) { return structured.ok ? 0 : 2; } catch (err) { const timeout = isDeadlineError(err) || deadline.signal?.aborted; - const message = err?.message || String(err); + const message = toErrorMessage(err); const structured = structuredError(message, { finishReason: timeout ? "timeout" : "error" }); if (jsonMode) writeJson(structured); else console.error(`lazyglm run failed: ${message}`); @@ -338,7 +425,7 @@ export async function main(argv) { } } -function parsePositiveIntegerFlag(value, defaultValue, name) { +function parsePositiveIntegerFlag(value: FlagValue, defaultValue: number | undefined, name: string): ParseResult { if (value === undefined) return { value: defaultValue }; if (value === true) return { error: `${name} requires a value` }; const normalized = typeof value === "string" ? value.replace(/_/g, "") : value; @@ -347,7 +434,7 @@ function parsePositiveIntegerFlag(value, defaultValue, name) { return { value: n }; } -function parseNonnegativeIntegerFlag(value, defaultValue, name) { +function parseNonnegativeIntegerFlag(value: FlagValue, defaultValue: number, name: string): ParseResult { if (value === undefined) return { value: defaultValue }; if (value === true) return { error: `${name} requires a value` }; const normalized = typeof value === "string" ? value.replace(/_/g, "") : value; @@ -356,7 +443,7 @@ function parseNonnegativeIntegerFlag(value, defaultValue, name) { return { value: n }; } -function parseNonnegativeNumberFlag(value, defaultValue, name) { +function parseNonnegativeNumberFlag(value: FlagValue, defaultValue: number, name: string): ParseResult { if (value === undefined) return { value: defaultValue }; if (value === true) return { error: `${name} requires a value` }; const n = Number(value); @@ -364,13 +451,13 @@ function parseNonnegativeNumberFlag(value, defaultValue, name) { return { value: n }; } -function runUsageError(message, jsonMode) { +function runUsageError(message: string, jsonMode: boolean): number { if (jsonMode) writeJson(structuredError(message, { finishReason: "error" })); else console.error(message); return 1; } -function structuredError(message, { finishReason = "error" } = {}) { +function structuredError(message: unknown, { finishReason = "error" }: { finishReason?: string } = {}): StructuredOutput { return { ok: false, result: null, @@ -382,12 +469,12 @@ function structuredError(message, { finishReason = "error" } = {}) { }; } -function structuredRunResult(res, { finishReason = res.finishReason, verification, errorMessage } = {}) { +function structuredRunResult(res: RunAgentResult, { finishReason = res.finishReason, verification, errorMessage }: RunResultOverrides = {}): StructuredOutput { const promptTokens = Number(res.promptTokens ?? res.tokensIn ?? 0) || 0; const completionTokens = Number(res.completionTokens ?? res.tokensOut ?? 0) || 0; const reasoningTokens = Number(res.reasoningTokens ?? 0) || 0; const ok = res.finished === true && finishReason === "finished" && (!verification || verification.pass === true); - const out = { + const out: StructuredOutput = { ok, result: res.finishSummary || res.result || null, finishReason: ok ? "finished" : finishReason || "error", @@ -410,7 +497,7 @@ function structuredRunResult(res, { finishReason = res.finishReason, verificatio return out; } -function structuredUltraworkResult(res) { +function structuredUltraworkResult(res: UltraworkResultShape): StructuredOutput { const history = Array.isArray(res.history) ? res.history : []; const last = history[history.length - 1]; const totals = history.reduce((acc, item) => { @@ -419,9 +506,9 @@ function structuredUltraworkResult(res) { acc.reasoningTokens += Number(item.reasoningTokens ?? 0) || 0; acc.toolCalls.push(...sanitizeToolCalls(item.toolCalls)); return acc; - }, { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, toolCalls: [] }); + }, { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, toolCalls: [] as StructuredOutput["toolCalls"] }); const finishReason = res.finishReason || (res.verified ? "finished" : "max_iterations"); - const out = { + const out: StructuredOutput = { ok: res.verified === true, result: res.finishSummary || last?.finishSummary || last?.result || null, finishReason, @@ -444,24 +531,24 @@ function structuredUltraworkResult(res) { return out; } -function sanitizeToolCalls(toolCalls) { - return (Array.isArray(toolCalls) ? toolCalls : []).map((tc) => ({ +function sanitizeToolCalls(toolCalls: unknown): StructuredOutput["toolCalls"] { + return (Array.isArray(toolCalls) ? toolCalls : []).map((tc: Partial) => ({ name: String(tc.name || "unknown"), turn: Number(tc.turn || 0), - status: ["ok", "denied", "error", "finish", "timeout"].includes(tc.status) ? tc.status : "ok", + status: ["ok", "denied", "error", "finish", "timeout"].includes(String(tc.status)) ? String(tc.status) : "ok", })); } -function writeJson(obj) { +function writeJson(obj: unknown): void { process.stdout.write(`${JSON.stringify(obj)}\n`); } -function formatSeconds(seconds) { +function formatSeconds(seconds: number): string { const n = Number(seconds); return Number.isInteger(n) ? `${n}s` : `${n.toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}s`; } -function readStdin() { +function readStdin(): Promise { return new Promise((resolve) => { let data = ""; process.stdin.setEncoding("utf8"); diff --git a/src/doctor.js b/src/doctor.ts similarity index 79% rename from src/doctor.js rename to src/doctor.ts index 0b11bf2..864cf12 100644 --- a/src/doctor.js +++ b/src/doctor.ts @@ -14,16 +14,40 @@ import { CONTEXT_BUDGET_FACTOR, resolveContextBudget, findCatalogModelEntry } fr import { readJson } from "./util.js"; import { fileURLToPath } from "node:url"; import { dirname } from "node:path"; +import type { ModelCatalog, ProviderConfig, RoleModelConfig } from "./types/index.js"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); const execP = promisify(exec); -export function reasoningConfigDetail(config, { preserveThinking } = {}) { +type CheckStatus = "ok" | "warn" | "fail"; + +interface DoctorCheck { + name: string; + status: CheckStatus; + detail: string; +} + +interface DoctorOptions { + cwd?: string; +} + +type DoctorCatalog = ModelCatalog & { version?: string }; + +type ReasoningConfig = Partial & { + provider?: ProviderConfig["provider"] | string; + reasoningEffort?: ProviderConfig["reasoningEffort"] | string; +}; + +function errorMessage(err: unknown): string { + return err && typeof err === "object" && "message" in err ? String((err as { message?: unknown }).message) : String(err); +} + +export function reasoningConfigDetail(config: ReasoningConfig | null | undefined, { preserveThinking }: { preserveThinking?: boolean } = {}): string { const cfg = config || {}; const provider = cfg.provider || "?"; const effort = cfg.reasoningEffort || "high"; - const preserved = preserveThinking ?? shouldPreserveThinking(provider); + const preserved = preserveThinking ?? shouldPreserveThinking(provider as ProviderConfig["provider"]); const control = thinkingControlForRequest({ provider, reasoningEffort: effort, @@ -37,26 +61,36 @@ export function reasoningConfigDetail(config, { preserveThinking } = {}) { return `provider=${provider} model=${cfg.modelId || cfg.model || "?"} role=${cfg.role || "default"} effort=${effort} thinking=${thinking} preserved_thinking=${preserved ? "on" : "off"}`; } -export async function doctor({ cwd } = {}) { +export async function doctor({ cwd }: DoctorOptions = {}) { const dir = cwd || process.cwd(); - const checks = []; - const ok = (name, detail) => { checks.push({ name, status: "ok", detail }); }; - const warn = (name, detail) => { checks.push({ name, status: "warn", detail }); }; - const fail = (name, detail) => { checks.push({ name, status: "fail", detail }); }; + const checks: DoctorCheck[] = []; + const ok = (name: string, detail: string): void => { checks.push({ name, status: "ok", detail }); }; + const warn = (name: string, detail: string): void => { checks.push({ name, status: "warn", detail }); }; + const fail = (name: string, detail: string): void => { checks.push({ name, status: "fail", detail }); }; - const catalog = await readJson(join(ROOT, "config", "model-catalog.json"), {}); + const catalog = await readJson(join(ROOT, "config", "model-catalog.json"), {}) || {}; // resolve provider config (now async + routing-aware) - let cfg; + let cfg: ProviderConfig; let providerError = null; try { cfg = await resolveProviderConfig({ role: "default" }); } catch (e) { - providerError = e.message; + providerError = errorMessage(e); // Honor LAZYGLM_MODEL in the fallback so the context check reports the // env-selected model, matching runtime routing (router.js pickModel). const fallbackModel = process.env.LAZYGLM_MODEL || catalog.current?.model || "glm-5.2"; - cfg = { baseURL: "?", provider: "?", model: fallbackModel, modelId: fallbackModel, role: "default", reasoningEffort: "high", apiKey: "***" }; + cfg = { + baseURL: "?", + provider: "?", + model: fallbackModel, + modelId: fallbackModel, + role: "default", + reasoningEffort: "high", + apiKey: "***", + timeout: 0, + maxRetries: 0, + }; } // provider config resolved? @@ -71,7 +105,7 @@ export async function doctor({ cwd } = {}) { // provider reachable + model available? if (!providerError) { - let models = []; + let models: string[] = []; try { models = await listModels(cfg); ok("reachable", `${cfg.baseURL} reachable, ${models.length} model(s) listed`); @@ -80,7 +114,7 @@ export async function doctor({ cwd } = {}) { if (cfg.provider === "ollama") { warn("reachable", `${cfg.baseURL} unreachable. Start Ollama: \`ollama serve\``); } else { - fail("reachable", `${cfg.baseURL} unreachable: ${e.message}`); + fail("reachable", `${cfg.baseURL} unreachable: ${errorMessage(e)}`); } } if (models.length) { @@ -145,9 +179,9 @@ export async function doctor({ cwd } = {}) { // routing sanity: verify roles resolve to models if (catalog.roles) { - const unresolved = []; - for (const [role, entry] of Object.entries(catalog.roles)) { - if (!catalog.models?.[entry.model]) unresolved.push(`${role}->${entry.model}`); + const unresolved: string[] = []; + for (const [role, entry] of Object.entries(catalog.roles) as Array<[string, RoleModelConfig]>) { + if (entry.model && !catalog.models?.[entry.model]) unresolved.push(`${role}->${entry.model}`); } if (unresolved.length) warn("routing", `roles pointing to unknown models: ${unresolved.join(", ")}`); else ok("routing", `all ${Object.keys(catalog.roles).length} roles resolve to catalog models`); diff --git a/src/installer.js b/src/installer.ts similarity index 85% rename from src/installer.js rename to src/installer.ts index 963f9bb..7526b8e 100644 --- a/src/installer.js +++ b/src/installer.ts @@ -7,6 +7,34 @@ import { join } from "node:path"; import { mkdir, writeFile, readFile, rm, unlink } from "node:fs/promises"; import { ensureDir, readJson, writeJson, gitInfo, looksLikeProject } from "./util.js"; +type InstallerConfig = Record & { + installedAt?: string; + version?: string; + provider?: unknown; + model?: string; + gitignoreOwnedByLazyglm?: boolean; + gitignoreFileOwnedByLazyglm?: boolean; + agentsOwnedByLazyglm?: boolean; +}; + +interface InstallOptions { + cwd?: string; + force?: boolean; +} + +interface InstallResult { + cwd: string; + created: string[]; + git: ReturnType; + isProject: boolean; +} + +interface UninstallResult { + cwd: string; + removed: string[]; + preserved: string[]; +} + const AGENTS_TEMPLATE = `# AGENTS.md This project uses **LazyGLM** — a GLM-native agent harness. Agents working in @@ -33,19 +61,19 @@ this repo should follow the rules below. Add project-specific guidance below as the codebase grows. `; -function isGitignoreEntry(line, entry) { +function isGitignoreEntry(line: string, entry: string): boolean { return line.replace(/\r$/, "") === entry; } -function gitignoreHasEntry(text, entry) { +function gitignoreHasEntry(text: string, entry: string): boolean { return text.split("\n").some((line) => isGitignoreEntry(line, entry)); } -function asConfig(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +function asConfig(value: unknown): InstallerConfig { + return value && typeof value === "object" && !Array.isArray(value) ? value as InstallerConfig : {}; } -async function configWithDefaults(config = {}) { +async function configWithDefaults(config: InstallerConfig = {}): Promise { config = asConfig(config); return { installedAt: config.installedAt || new Date().toISOString(), @@ -56,9 +84,9 @@ async function configWithDefaults(config = {}) { }; } -export async function install({ cwd, force = false } = {}) { +export async function install({ cwd, force = false }: InstallOptions = {}): Promise { const dir = cwd || process.cwd(); - const created = []; + const created: string[] = []; const lazyDir = join(dir, ".lazyglm"); await ensureDir(lazyDir); @@ -128,11 +156,11 @@ export async function install({ cwd, force = false } = {}) { return { cwd: dir, created, git: gitInfo(dir), isProject: looksLikeProject(dir) }; } -export async function uninstall({ cwd } = {}) { +export async function uninstall({ cwd }: { cwd?: string } = {}): Promise { const dir = cwd || process.cwd(); const lazyDir = join(dir, ".lazyglm"); - const removed = []; - const preserved = []; + const removed: string[] = []; + const preserved: string[] = []; // Capture ownership BEFORE removing .lazyglm/, since config.json lives inside // that directory. install() records which artifacts it created; if a marker is @@ -194,10 +222,10 @@ export async function uninstall({ cwd } = {}) { return { cwd: dir, removed, preserved }; } -async function readVersion() { +async function readVersion(): Promise { try { - const pkg = JSON.parse(await readFile(join(import.meta.dirname, "..", "package.json"), "utf8")); - return pkg.version; + const pkg = JSON.parse(await readFile(join(import.meta.dirname, "..", "package.json"), "utf8")) as { version?: string }; + return pkg.version || "0.0.0"; } catch { return "0.0.0"; } diff --git a/src/onboard.js b/src/onboard.ts similarity index 83% rename from src/onboard.js rename to src/onboard.ts index 8d6a790..eadbd8f 100644 --- a/src/onboard.js +++ b/src/onboard.ts @@ -6,12 +6,26 @@ // burst stdin is not lost between sequential prompts (a known readline race). import { saveUserConfig, loadUserConfig, isOnboarded, normalizeProvider, isSupportedProvider, SUPPORTED_PROVIDERS } from "./config.js"; import { nowIso } from "./util.js"; +import type { PersistedUserConfig, Provider } from "./types/index.js"; + +interface LineQueueLike { + next(): Promise; +} + +interface OutputLike { + write(text: string): unknown; +} + +interface OnboardingOptions { + queue?: LineQueueLike; + output?: OutputLike; +} /** * True when there is no usable key source and onboarding should run. * Env var, an onboarded config file, or ollama all satisfy "no onboarding". */ -export async function needsOnboarding() { +export async function needsOnboarding(): Promise { if (process.env.LAZYGLM_API_KEY) return false; const envProvider = normalizeProvider(process.env.LAZYGLM_PROVIDER); const cfg = await loadUserConfig({ force: true }); @@ -28,13 +42,13 @@ export async function needsOnboarding() { * * @param {object} opts - { queue, output } */ -export async function runOnboarding({ queue, output } = {}) { +export async function runOnboarding({ queue, output }: OnboardingOptions = {}): Promise { const out = output || process.stdout; const isTTY = !!process.stdin.isTTY; - const ask = async (q) => { + const ask = async (q: string): Promise => { out.write(q); - const line = await queue.next(); + const line = await queue!.next(); if (!isTTY) out.write((line || "") + "\n"); // echo for piped/recorded runs return (line || "").trim(); }; @@ -49,7 +63,7 @@ export async function runOnboarding({ queue, output } = {}) { out.write(" ollama local OpenAI-compatible Ollama endpoint (keyless)\n\n"); }; - let provider; + let provider: Provider; // eslint-disable-next-line no-constant-condition while (true) { const providerAns = await ask("Provider [zai] (zai|nous|ollama, or help): "); @@ -78,7 +92,7 @@ export async function runOnboarding({ queue, output } = {}) { const modelAns = await ask("Default model [glm-5.2]: "); const model = modelAns || "glm-5.2"; - const config = { onboarded: true, provider, model, createdAt: nowIso() }; + const config: PersistedUserConfig = { onboarded: true, provider, model, createdAt: nowIso() }; if (provider !== "ollama") config.api_key = apiKey; const path = await saveUserConfig(config); out.write(`\n✅ Saved to ${path}\n`); diff --git a/src/prompt.js b/src/prompt.ts similarity index 85% rename from src/prompt.js rename to src/prompt.ts index 355bc9e..1cae1d7 100644 --- a/src/prompt.js +++ b/src/prompt.ts @@ -1,21 +1,24 @@ -// @ts-check - // Side-effect-free prompt composition for the one-shot runtime and interactive // REPL. Model/tier facts are supplied by callers from config/model-catalog.json. import { nowIso } from "./util.js"; +import type { ModelCatalogEntry } from "./types/index.js"; -/** - * @typedef {{ isRepo: boolean, branch?: string, root?: string }} PromptGitInfo - * @typedef {object} PromptOptions - * @property {string} cwd - * @property {PromptGitInfo} git - * @property {string} model - * @property {string[]} [injects] - * @property {string} [extra] - * @property {string} [tier] - * @property {number|string} [contextWindow] - * @property {string} [description] - */ +export interface PromptGitInfo { + isRepo: boolean; + branch?: string; + root?: string; +} + +export interface PromptOptions { + cwd: string; + git: PromptGitInfo; + model: string; + injects?: string[]; + extra?: string; + tier?: string; + contextWindow?: number | string; + description?: string; +} export const RUNTIME_WORKING_PROMPT = `You are LazyGLM, an autonomous software engineering agent driven by a GLM model. You operate inside a real project directory on the user's machine via tools. @@ -41,8 +44,7 @@ HOW YOU OPERATE (agentic - you have tools): - Keep your terminal output clean and readable. - When the user's request is fully done, call the finish tool with a one-line summary.`; -/** @type {Record} */ -const TIER_GUIDANCE = { +const TIER_GUIDANCE: Record = { "high-end": "Use this tier for long-horizon coding, architecture, complex debugging, and work that benefits from the largest GLM context.", "high-end-fast": "Use this tier when the task still needs strong coding ability but should spend fewer latency/cost resources than the flagship route.", strong: "Use this tier for general implementation and review where full flagship context is not required.", @@ -54,7 +56,7 @@ const TIER_GUIDANCE = { * @param {{ tier?: string, description?: string }} [info] * @returns {string} */ -export function modelTierGuidance(info = {}) { +export function modelTierGuidance(info: Pick = {}): string { const tier = info.tier || ""; const tierText = TIER_GUIDANCE[tier] || ""; const description = info.description ? String(info.description).trim() : ""; @@ -66,7 +68,7 @@ export function modelTierGuidance(info = {}) { * @param {number|string|undefined} value * @returns {string} */ -function formatContextWindow(value) { +function formatContextWindow(value: number | string | undefined): string { const n = Number(value); if (Number.isFinite(n) && n > 0) return n.toLocaleString("en-US"); return value ? String(value) : "catalog entry unavailable"; @@ -76,7 +78,12 @@ function formatContextWindow(value) { * @param {{ model?: string, tier?: string, contextWindow?: number|string, description?: string }} [options] * @returns {string} */ -export function buildGlmNativeBlock({ model, tier, contextWindow, description } = {}) { +export function buildGlmNativeBlock({ + model, + tier, + contextWindow, + description, +}: Partial> = {}): string { const guidance = modelTierGuidance({ tier, description }); const lines = [ "GLM-NATIVE OPERATING CONTRACT", @@ -96,7 +103,7 @@ export function buildGlmNativeBlock({ model, tier, contextWindow, description } * @param {PromptOptions} options * @returns {string} */ -function buildEnvironmentBlock({ cwd, git, model }) { +function buildEnvironmentBlock({ cwd, git, model }: PromptOptions): string { return [ "ENVIRONMENT", `- cwd: ${cwd}`, @@ -111,7 +118,7 @@ function buildEnvironmentBlock({ cwd, git, model }) { * @param {PromptOptions} options * @returns {string} */ -export function buildRuntimePrompt(options) { +export function buildRuntimePrompt(options: PromptOptions): string { const parts = [ buildGlmNativeBlock(options), RUNTIME_WORKING_PROMPT, @@ -128,7 +135,7 @@ export function buildRuntimePrompt(options) { * @param {PromptOptions} options * @returns {string} */ -export function buildReplPrompt(options) { +export function buildReplPrompt(options: PromptOptions): string { const parts = [ buildGlmNativeBlock(options), REPL_PERSONA_PROMPT, diff --git a/src/repl.js b/src/repl.ts similarity index 78% rename from src/repl.js rename to src/repl.ts index 116d152..7a9fa40 100644 --- a/src/repl.js +++ b/src/repl.ts @@ -34,6 +34,23 @@ import { gitInfo, truncate } from "./util.js"; import { renderBanner } from "./banner.js"; import { renderStatus } from "./status.js"; import { buildReplPrompt, modelTierGuidance } from "./prompt.js"; +import type { ContextMessage } from "./agent/context.js"; +import type { SessionInfo } from "./sessions.js"; +import type { + ChatCompletion, + ChatUsage, + EffectiveBundle, + FinishToolResult, + ModelCatalog, + Provider, + ProviderConfig, + RoleName, + RoutingDecision, + SessionRecord, + StreamDelta, + ToolCall, + ToolHandlerResult, +} from "./types/index.js"; const GRAY = "\x1b[90m"; const DIM = "\x1b[2m"; @@ -50,20 +67,62 @@ const TOOL_DIVIDER_RULE = "─".repeat(18); const TURN_RULE_WIDTH = 60; const TURN_RULE = "─".repeat(TURN_RULE_WIDTH); -function ansi(code, { isTTY = true } = {}) { +interface RenderOptions { + isTTY?: boolean; +} + +interface TokenCounts { + prompt: number; + completion: number; + reasoning: number; +} + +interface LaunchFlags { + continue?: boolean; + yolo?: boolean; + model?: string; + provider?: Provider; + role?: RoleName; + contextBudget?: number; +} + +interface LaunchOptions { + cwd?: string; + flags?: LaunchFlags; +} + +interface ContextBudgetCommandOptions { + model?: string | null; + catalog?: ModelCatalog; + manualBudget?: number | null; +} + +type ContextBudgetCommandResult = + | { error: string; budget?: undefined; manualBudget?: undefined; mode?: undefined; action?: undefined } + | { budget: number; manualBudget: number | null; mode: "manual" | "catalog"; action: "show" | "set"; error?: undefined }; + +interface TurnSummary { + hadError: boolean; + wroteFiles: boolean; + explicitComplexity: boolean; +} + +type PromptSignal = ReturnType; + +function ansi(code: string, { isTTY = true }: RenderOptions = {}): string { return isTTY ? code : ""; } -function routingBundleLabel(bundle) { +function routingBundleLabel(bundle: EffectiveBundle): string { return `${bundle.model}/${bundle.reasoningEffort}`; } -export function formatRoutingNotice(decision, opts = {}) { +export function formatRoutingNotice(decision: RoutingDecision, opts: RenderOptions = {}): string { const text = `routing: ${routingBundleLabel(decision.from)} -> ${routingBundleLabel(decision.to)} (${decision.reason})`; return `${ansi(CYAN, opts)}${text}${ansi(RESET, opts)}`; } -function displayValue(value) { +function displayValue(value: unknown): string { if (value === undefined || value === null) return ""; if (typeof value === "string") return value; try { @@ -73,33 +132,54 @@ function displayValue(value) { } } -export function formatReasoning(text = "", opts = {}) { +export function formatReasoning(text = "", opts: RenderOptions = {}): string { return `${ansi(GRAY, opts)}✶ ${text}`; } -export function formatText(text = "") { +export function formatText(text = ""): string { return `💬 ${text}`; } -export function formatToolCall(name, args = "", opts = {}) { +export function formatToolCall(name: string | null | undefined, args: unknown = "", opts: RenderOptions = {}): string { const argText = truncate(displayValue(args), 100); return `${TOOL_INDENT}${ansi(CYAN, opts)}🔧 ${name}${ansi(RESET, opts)}${ansi(DIM, opts)}(${argText})${ansi(RESET, opts)}`; } -export function formatToolResult(result = "", opts = {}) { +export function formatToolResult(result: unknown = "", opts: RenderOptions = {}): string { const preview = truncate(displayValue(result), 400).replace(/\n/g, `\n${TOOL_INDENT}${ansi(GREEN, opts)}`); return `${TOOL_INDENT}${ansi(GREEN, opts)}↳ ${preview}${ansi(RESET, opts)}`; } -export function turnDivider(opts = {}) { +export function turnDivider(opts: RenderOptions = {}): string { return `${TOOL_INDENT}${ansi(DIM, opts)}${TOOL_DIVIDER_RULE} tools ${TOOL_DIVIDER_RULE}${ansi(RESET, opts)}`; } -export function formatExitMarker(opts = {}) { +export function formatExitMarker(opts: RenderOptions = {}): string { return `${ansi(DIM, opts)}bye.${ansi(RESET, opts)}`; } -export function formatCost(cumulative = {}, lastTurn = null, opts = {}) { +function usageFromUnknown(value: unknown): TokenCounts { + const record = value && typeof value === "object" ? value as Record : {}; + return { + prompt: Number(record.prompt || 0) || 0, + completion: Number(record.completion || 0) || 0, + reasoning: Number(record.reasoning || 0) || 0, + }; +} + +function reasoningTokens(usage: ChatUsage | null | undefined): number { + return Number(usage?.completion_tokens_details?.reasoning_tokens || usage?.reasoning_tokens || 0) || 0; +} + +function toErrorMessage(err: unknown): string { + return err && typeof err === "object" && "message" in err ? String((err as { message?: unknown }).message) : String(err); +} + +function isFinishResult(value: unknown): value is FinishToolResult { + return !!value && typeof value === "object" && (value as { __finish?: unknown }).__finish === true; +} + +export function formatCost(cumulative: Partial = {}, lastTurn: Partial | null = null, opts: RenderOptions = {}): string { const c = cumulative && typeof cumulative === "object" ? cumulative : {}; const lt = lastTurn && typeof lastTurn === "object" ? lastTurn : {}; const lastPrompt = lt.prompt || 0; @@ -132,39 +212,42 @@ export function formatCost(cumulative = {}, lastTurn = null, opts = {}) { // above and below the turn (symmetric). Non-TTY: a plain `> text` echo with // zero ANSI and no rule, so piped output stays clean + parseable. The existing // `── tools ──` divider nests inside this frame and is left untouched. -export function turnRule({ isTTY = true } = {}) { +export function turnRule({ isTTY = true }: RenderOptions = {}): string { return isTTY ? `${DIM}${TURN_RULE}${RESET}` : ""; } -export function stripControlSequences(text = "") { +export function stripControlSequences(text = ""): string { return String(text).replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ""); } -export function turnStart(userText, { isTTY = true } = {}) { +export function turnStart(userText: unknown, { isTTY = true }: RenderOptions = {}): string { if (!isTTY) { // Single-line truncation: the shared truncate() inserts a newline before // its marker, which would break the "standalone `> text` line" contract // for piped output. Use a flat inline marker instead. - const clean = stripControlSequences(userText ?? ""); + const clean = stripControlSequences(String(userText ?? "")); const display = clean.length > 100 ? clean.slice(0, 100) + "…" : clean; return `> ${display}\n`; } return `\n${turnRule({ isTTY })}\n`; } -export function turnEnd({ isTTY = true } = {}) { +export function turnEnd({ isTTY = true }: RenderOptions = {}): string { if (!isTTY) return ""; return `${turnRule({ isTTY })}\n\n`; } -export function parseContextBudgetInput(value) { +export function parseContextBudgetInput(value: unknown): number | null { if (value === undefined || value === null || value === "") return null; const normalized = String(value).replace(/_/g, ""); const n = Number(normalized); return Number.isInteger(n) && n > 0 ? n : null; } -export function resolveContextBudgetCommand(argStr, { model, catalog, manualBudget = null } = {}) { +export function resolveContextBudgetCommand( + argStr: unknown, + { model, catalog, manualBudget = null }: ContextBudgetCommandOptions = {}, +): ContextBudgetCommandResult { const arg = String(argStr || "").trim(); const derivedBudget = () => resolveContextBudget(model, catalog); if (!arg) { @@ -180,7 +263,7 @@ export function resolveContextBudgetCommand(argStr, { model, catalog, manualBudg return { budget: parsed, manualBudget: parsed, mode: "manual", action: "set" }; } -export function hasManualRoutingOverride(flags = {}) { +export function hasManualRoutingOverride(flags: Partial = {}): boolean { return !!(flags.model || flags.role); } @@ -193,22 +276,27 @@ export function hasManualRoutingOverride(flags = {}) { * up front and are handed out in order. */ class LineQueue { - constructor({ input, output }) { + lines: string[]; + waiters: Array<(line: string | null) => void>; + closed: boolean; + rl: readline.Interface; + + constructor({ input, output }: { input: NodeJS.ReadableStream; output: NodeJS.WritableStream }) { this.lines = []; this.waiters = []; this.closed = false; this.rl = readline.createInterface({ input, output }); - this.rl.on("line", (line) => { - if (this.waiters.length) this.waiters.shift()(line); + this.rl.on("line", (line: string) => { + if (this.waiters.length) this.waiters.shift()?.(line); else this.lines.push(line); }); this.rl.on("close", () => { this.closed = true; - while (this.waiters.length) this.waiters.shift()(null); + while (this.waiters.length) this.waiters.shift()?.(null); }); } - next() { - if (this.lines.length) return Promise.resolve(this.lines.shift()); + next(): Promise { + if (this.lines.length) return Promise.resolve(this.lines.shift() ?? null); if (this.closed) return Promise.resolve(null); return new Promise((resolve) => this.waiters.push(resolve)); } @@ -216,27 +304,27 @@ class LineQueue { // --- streaming renderer (shared across REPL turns and /ultrawork) --- let streamOpen = false; -let streamMode = null; // "text" | "reasoning" +let streamMode: "text" | "reasoning" | null = null; let toolDividerShown = false; -let toolDividerKey = null; +let toolDividerKey: string | null = null; -function renderOpts() { +function renderOpts(): RenderOptions { return { isTTY: process.stdout.isTTY === true }; } -function resetTurnDivider(key = null) { +function resetTurnDivider(key: string | null = null): void { toolDividerKey = key; toolDividerShown = false; } -function writeTurnDivider(key = toolDividerKey) { +function writeTurnDivider(key: string | null = toolDividerKey): void { if (key !== toolDividerKey) resetTurnDivider(key); if (toolDividerShown) return; toolDividerShown = true; if (process.stdout.isTTY === true) process.stdout.write(`${turnDivider(renderOpts())}\n`); } -function closeStream() { +function closeStream(): void { if (streamOpen) { process.stdout.write(ansi(RESET, renderOpts()) + "\n"); streamOpen = false; @@ -244,7 +332,7 @@ function closeStream() { } } -function renderDelta(d) { +function renderDelta(d: StreamDelta): void { const opts = renderOpts(); if (d.type === "reasoning") { if (!streamOpen) { @@ -269,34 +357,35 @@ function renderDelta(d) { } } -function extractQuoted(s) { +function extractQuoted(s: string): string | null { const m = s.match(/"([^"]*)"/); return m ? m[1] : null; } -function extractFlag(s, flag) { +function extractFlag(s: string, flag: string): string | null { const re = new RegExp(`--${flag}\\s+"([^"]*)"`); const m = s.match(re); return m ? m[1] : null; } -export function replPromptTarget({ stdinIsTTY, stdoutIsTTY } = {}) { +export function replPromptTarget({ stdinIsTTY, stdoutIsTTY }: { stdinIsTTY?: boolean; stdoutIsTTY?: boolean } = {}): "stdout" | "stderr" | null { if (stdoutIsTTY) return "stdout"; if (stdinIsTTY) return "stderr"; return null; } /** Rebuild a Context's messages from a session's event records. */ -export function replayIntoContext(events, ctx) { +export function replayIntoContext(events: SessionRecord[], ctx: Context): void { for (const ev of events) { if (ev.type === "user") { - ctx.push({ role: "user", content: ev.content }); + ctx.push({ role: "user", content: String(ev.content ?? "") }); } else if (ev.type === "assistant") { - const m = { role: "assistant", content: ev.content || "" }; + const m: ContextMessage = { role: "assistant", content: String(ev.content || "") }; // Restore GLM preserved thinking so resumed sessions replay prior // reasoning_content across turns (the provider gates the wire payload). - if (ev.reasoning_content) m.reasoning_content = ev.reasoning_content; - if (ev.tool_calls && ev.tool_calls.length) { - m.tool_calls = ev.tool_calls.map((tc) => ({ + if (ev.reasoning_content) m.reasoning_content = String(ev.reasoning_content); + const toolCalls = Array.isArray(ev.tool_calls) ? ev.tool_calls as ToolCall[] : []; + if (toolCalls.length) { + m.tool_calls = toolCalls.map((tc) => ({ id: tc.id, type: "function", function: { name: tc.name, arguments: JSON.stringify(tc.arguments) }, @@ -304,7 +393,7 @@ export function replayIntoContext(events, ctx) { } ctx.push(m); } else if (ev.type === "tool") { - ctx.push({ role: "tool", tool_call_id: ev.tool_call_id, content: ev.content }); + ctx.push({ role: "tool", tool_call_id: ev.tool_call_id, content: String(ev.content ?? "") }); } // session / usage / compact / log records are not part of the message history } @@ -317,11 +406,11 @@ export function replayIntoContext(events, ctx) { * * @returns {{ cumulative: object, lastTurn: object|null, sessionStartMs: number }} */ -export function replayTelemetry(events) { +export function replayTelemetry(events: SessionRecord[] = []): { cumulative: TokenCounts; lastTurn: TokenCounts | null; sessionStartMs: number } { const cumulative = { prompt: 0, completion: 0, reasoning: 0 }; - let lastTurn = null; + let lastTurn: TokenCounts | null = null; let sessionStartMs = Date.now(); - for (const ev of events || []) { + for (const ev of events) { if (ev.type === "session" && ev.t) { const ms = Date.parse(ev.t); if (!Number.isNaN(ms)) sessionStartMs = ms; @@ -332,13 +421,13 @@ export function replayTelemetry(events) { // resumed before the telemetry-restore patch persisted cumulative // snapshots that restarted from zero, so last-one-wins would under-report // the true session total for those legacy histories. - const u = ev.usage; + const u = ev.usage && typeof ev.usage === "object" ? ev.usage as ChatUsage : null; if (u && typeof u === "object") { - const reasoning = u.completion_tokens_details?.reasoning_tokens || u.reasoning_tokens || 0; - cumulative.prompt += u.prompt_tokens || 0; - cumulative.completion += u.completion_tokens || 0; + const reasoning = reasoningTokens(u); + cumulative.prompt += Number(u.prompt_tokens || 0) || 0; + cumulative.completion += Number(u.completion_tokens || 0) || 0; cumulative.reasoning += reasoning; - lastTurn = { prompt: u.prompt_tokens || 0, completion: u.completion_tokens || 0, reasoning }; + lastTurn = { prompt: Number(u.prompt_tokens || 0) || 0, completion: Number(u.completion_tokens || 0) || 0, reasoning }; } } } @@ -346,17 +435,17 @@ export function replayTelemetry(events) { } /** Render runAgent / runUltrawork events into the REPL (compact). */ -function renderUltraworkEvent(ev) { +function renderUltraworkEvent(ev: Record & { type?: string }): void { switch (ev.type) { case "start": resetTurnDivider("ultrawork:start"); - console.log(`\n🚀 iteration start | model: ${ev.model} | task: ${truncate(ev.task, 120)}`); + console.log(`\n🚀 iteration start | model: ${ev.model} | task: ${truncate(String(ev.task ?? ""), 120)}`); break; case "reasoning_delta": - renderDelta({ type: "reasoning", text: ev.text }); + renderDelta({ type: "reasoning", text: String(ev.text ?? "") }); break; case "assistant_delta": - renderDelta({ type: "text", text: ev.text }); + renderDelta({ type: "text", text: String(ev.text ?? "") }); break; case "tool_call_start": closeStream(); @@ -364,7 +453,7 @@ function renderUltraworkEvent(ev) { case "tool_call": closeStream(); writeTurnDivider(ev.turn === undefined ? "ultrawork" : `ultrawork:${ev.turn}`); - console.log(formatToolCall(ev.name, ev.input, renderOpts())); + console.log(formatToolCall(String(ev.name ?? "unknown"), ev.input, renderOpts())); break; case "tool_result": console.log(formatToolResult(ev.result, renderOpts())); @@ -375,14 +464,14 @@ function renderUltraworkEvent(ev) { break; // per-turn usage is noisy; /cost shows the cumulative total case "finish": closeStream(); - console.log(`${GREEN} ✅ ${truncate(ev.summary, 400)}${RESET}`); + console.log(`${GREEN} ✅ ${truncate(String(ev.summary ?? ""), 400)}${RESET}`); break; case "ultrawork_iteration": closeStream(); console.log(`\n${CYAN}🔁 iteration ${ev.iteration}/${ev.max}${RESET}`); break; case "ultrawork_verify": - console.log(` verify: ${ev.pass ? GREEN + "PASS ✅" : YELLOW + "FAIL ❌"}${RESET} — ${truncate(ev.reason, 200)}`); + console.log(` verify: ${ev.pass ? GREEN + "PASS ✅" : YELLOW + "FAIL ❌"}${RESET} — ${truncate(String(ev.reason ?? ""), 200)}`); break; case "retry": closeStream(); @@ -392,7 +481,7 @@ function renderUltraworkEvent(ev) { console.log(`${DIM} (compacted #${ev.compactionCount})${RESET}`); break; case "blocked": - console.log(`${YELLOW} ⛔ ${ev.tool}: ${ev.reasons.join("; ")}${RESET}`); + console.log(`${YELLOW} ⛔ ${ev.tool}: ${(Array.isArray(ev.reasons) ? ev.reasons : []).map(String).join("; ")}${RESET}`); break; case "error": closeStream(); @@ -407,7 +496,7 @@ function renderUltraworkEvent(ev) { * Launch the interactive REPL. * @param {object} opts - { cwd, flags: { continue, yolo, model, provider } } */ -export async function launchREPL({ cwd, flags = {} } = {}) { +export async function launchREPL({ cwd, flags = {} }: LaunchOptions = {}): Promise { const dir = cwd || process.cwd(); // Shared line queue over stdin — used by onboarding (if it runs) and the REPL. @@ -421,23 +510,23 @@ export async function launchREPL({ cwd, flags = {} } = {}) { if (await needsOnboarding()) { try { await runOnboarding({ queue, output: process.stdout }); - } catch (e) { - console.error(`\n❌ ${e.message}`); + } catch (e: unknown) { + console.error(`\n❌ ${toErrorMessage(e)}`); process.exit(1); } } // 2. Resolve provider config (env > config file > catalog default) - let providerConfig; + let providerConfig: ProviderConfig; try { providerConfig = await resolveProviderConfig({ model: flags.model, provider: flags.provider, role: flags.role || "default" }); - } catch (e) { - console.error(`\n❌ ${e.message}`); + } catch (e: unknown) { + console.error(`\n❌ ${toErrorMessage(e)}`); process.exit(1); } let currentModel = providerConfig.modelId; - const catalog = await loadCatalog(); - let manualContextBudget = Number.isInteger(flags.contextBudget) && flags.contextBudget > 0 ? flags.contextBudget : null; + const catalog: ModelCatalog = await loadCatalog(); + let manualContextBudget = typeof flags.contextBudget === "number" && Number.isInteger(flags.contextBudget) && flags.contextBudget > 0 ? flags.contextBudget : null; let contextBudget = manualContextBudget ?? resolveContextBudget(providerConfig.model, catalog); const adaptiveState = createAdaptiveRoutingState({ manualOverride: hasManualRoutingOverride(flags) }); @@ -458,7 +547,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { const gi = gitInfo(dir); const ctx = new Context({ model: currentModel, budget: contextBudget, preserveThinking: shouldPreserveThinking(providerConfig.provider) }); const startRes = await engine.fire("SessionStart", {}); - const activeModelInfo = () => { + const activeModelInfo = (): { tier?: string; description?: string; contextWindow?: number; tierReason: string } => { const entry = findCatalogModelEntry(providerConfig.model || currentModel, catalog) || findCatalogModelEntry(currentModel, catalog); const tier = entry?.tier; const description = entry?.description; @@ -466,7 +555,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { const tierReason = modelTierGuidance({ tier, description }); return { tier, description, contextWindow, tierReason }; }; - const refreshSystemPrompt = () => { + const refreshSystemPrompt = (): void => { const info = activeModelInfo(); ctx.setSystem(buildReplPrompt({ cwd: dir, @@ -484,14 +573,14 @@ export async function launchREPL({ cwd, flags = {} } = {}) { engine.setMeta({ model: currentModel, transcriptPath: null, permissionMode: yolo ? "yolo" : "auto" }); // 6. Cost tracking - const cumulative = { prompt: 0, completion: 0, reasoning: 0 }; + const cumulative: TokenCounts = { prompt: 0, completion: 0, reasoning: 0 }; // Last-turn usage + timing snapshot, surfaced by /status. lastTurn is null // until the first turn completes; lastTurnMs includes retry backoff (wall-clock // around chat()), so it is a human-facing figure, not a latency benchmark. - let lastTurn = null; - let lastTurnMs = null; + let lastTurn: TokenCounts | null = null; + let lastTurnMs: number | null = null; let sessionStartMs = Date.now(); - const restoreTelemetry = (events) => { + const restoreTelemetry = (events: SessionRecord[]): void => { const restored = replayTelemetry(events); cumulative.prompt = restored.cumulative.prompt; cumulative.completion = restored.cumulative.completion; @@ -505,7 +594,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { }; // 7. Session (--continue resumes the last session file; otherwise fresh) - let session; + let session: SessionInfo | null = null; if (flags.continue) { const last = await lastSession(); if (last) { @@ -541,14 +630,14 @@ export async function launchREPL({ cwd, flags = {} } = {}) { }), ); - const resolveRoutingCandidate = async (role) => { + const resolveRoutingCandidate = async (role: RoleName): Promise<{ config: ProviderConfig; bundle: EffectiveBundle }> => { const config = await resolveProviderConfig({ provider: flags.provider, role }); return { config, bundle: effectiveBundleFromProviderConfig(config, catalog) }; }; - const currentRoutingBundle = async () => effectiveBundleFromProviderConfig(providerConfig, catalog); + const currentRoutingBundle = async (): Promise => effectiveBundleFromProviderConfig(providerConfig, catalog); - const applyRoutingDecision = async (decision, nextConfig) => { + const applyRoutingDecision = async (decision: RoutingDecision | null, nextConfig: ProviderConfig): Promise => { if (!decision) return false; providerConfig = nextConfig; currentModel = nextConfig.modelId; @@ -571,7 +660,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { return true; }; - const maybeRouteForPrompt = async (signal) => { + const maybeRouteForPrompt = async (signal: PromptSignal): Promise => { if (adaptiveState.manualOverride) return false; const currentBundle = await currentRoutingBundle(); const candidate = await resolveRoutingCandidate(signal.role); @@ -584,7 +673,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { return applyRoutingDecision(decision, candidate.config); }; - const maybeRouteForToolResult = async () => { + const maybeRouteForToolResult = async (): Promise => { if (adaptiveState.manualOverride) return false; const currentBundle = await currentRoutingBundle(); const candidate = await resolveRoutingCandidate(highRole()); @@ -596,7 +685,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { return applyRoutingDecision(decision, candidate.config); }; - const maybeRouteAfterUserTurn = async (turnSummary) => { + const maybeRouteAfterUserTurn = async (turnSummary: Partial): Promise => { if (adaptiveState.manualOverride) return false; const currentBundle = await currentRoutingBundle(); const candidate = await resolveRoutingCandidate("quick"); @@ -610,7 +699,7 @@ export async function launchREPL({ cwd, flags = {} } = {}) { }; // --- slash commands (closure over mutable REPL state) --- - const handleSlash = async (input) => { + const handleSlash = async (input: string): Promise<"exit" | void> => { const body = input.slice(1); const sp = body.indexOf(" "); const cmd = (sp >= 0 ? body.slice(0, sp) : body).toLowerCase(); @@ -664,8 +753,8 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); const info = activeModelInfo(); const tierNote = info.tier ? ` | tier: ${info.tier}${info.tierReason ? ` - ${info.tierReason}` : ""}` : ""; console.log(`${GREEN} ✓ model: ${currentModel}${tierNote}${RESET}`); - } catch (e) { - console.log(`${YELLOW} ✗ ${e.message}${RESET}`); + } catch (e: unknown) { + console.log(`${YELLOW} ✗ ${toErrorMessage(e)}${RESET}`); } return; } @@ -675,7 +764,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); catalog, manualBudget: manualContextBudget, }); - if (res.error) { + if (res.error !== undefined) { console.log(`${YELLOW} ${res.error}${RESET}`); return; } @@ -803,7 +892,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); config: ultraConfig, budget: ultraBudget, completionPromise, - verifyCommand, + verifyCommand: verifyCommand ?? undefined, maxIterations: 3, maxTurns: 60, onEvent: renderUltraworkEvent, @@ -827,8 +916,8 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); // displayText: the raw user input as typed, used ONLY for the non-TTY `> text` // echo so piped output logs the user's command rather than the expanded skill // body. Defaults to userContent for non-skill turns. (PR #26 Codex P2.) - const runAgentTurn = async (userContent, displayText = userContent) => { - const turnSummary = { + const runAgentTurn = async (userContent: string, displayText = userContent): Promise => { + const turnSummary: TurnSummary = { hadError: false, wroteFiles: false, explicitComplexity: adaptiveState.explicitComplexityInCurrentUserTurn, @@ -855,7 +944,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); }, }); - let resp; + let resp: ChatCompletion; const turnStartMs = Date.now(); try { resp = await chat({ @@ -864,15 +953,15 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); tools: TOOL_SPECS, config: providerConfig, onDelta: renderDelta, - onRetry: (r) => { + onRetry: (r: { attempt: number; reason: string; delay: number }) => { closeStream(); process.stdout.write(`${YELLOW} ⏳ retry ${r.attempt}: ${r.reason} (${r.delay}ms)${RESET}\n`); }, }); - } catch (err) { + } catch (err: unknown) { closeStream(); turnSummary.hadError = true; - process.stdout.write(`\n${YELLOW}❌ ${err.message}${RESET}\n`); + process.stdout.write(`\n${YELLOW}❌ ${toErrorMessage(err)}${RESET}\n`); process.stdout.write(turnEnd(renderOpts())); return turnSummary; } @@ -881,11 +970,11 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); ctx.recordUsage(resp.usage); const u = resp.usage || {}; - const reasoning = u.completion_tokens_details?.reasoning_tokens || u.reasoning_tokens || 0; - cumulative.prompt += u.prompt_tokens || 0; - cumulative.completion += u.completion_tokens || 0; + const reasoning = reasoningTokens(u); + cumulative.prompt += Number(u.prompt_tokens || 0) || 0; + cumulative.completion += Number(u.completion_tokens || 0) || 0; cumulative.reasoning += reasoning; - lastTurn = { prompt: u.prompt_tokens || 0, completion: u.completion_tokens || 0, reasoning }; + lastTurn = { prompt: Number(u.prompt_tokens || 0) || 0, completion: Number(u.completion_tokens || 0) || 0, reasoning }; await appendEvent(session, { type: "usage", usage: u, cumulative: { ...cumulative } }); closeStream(); @@ -911,13 +1000,14 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); writeTurnDivider(); let finished = false; for (const tc of resp.tool_calls) { - const handler = TOOL_HANDLERS[tc.name]; + const toolName = String(tc.name); + const handler = TOOL_HANDLERS[toolName]; if (!handler) { - const m = `Error: unknown tool '${tc.name}'. Available: read_file, write_file, patch_file, list_dir, grep, run_shell, finish.`; + const m = `Error: unknown tool '${toolName}'. Available: read_file, write_file, patch_file, list_dir, grep, run_shell, finish.`; ctx.push({ role: "tool", tool_call_id: tc.id, content: m }); - await appendEvent(session, { type: "tool", tool_call_id: tc.id, name: tc.name, content: m }); + await appendEvent(session, { type: "tool", tool_call_id: tc.id, name: toolName, content: m }); const signal = observeToolResult(adaptiveState, { - toolName: tc.name, + toolName, toolInput: tc.arguments, result: m, }); @@ -927,19 +1017,19 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); continue; } const pre = await engine.fire("PreToolUse", { - tool_name: tc.name, + tool_name: toolName, tool_input: tc.arguments, tool_use_id: tc.id, }); - let resultStr; + let resultStr: string; if (pre.blocks.length) { resultStr = `Blocked by hook:\n${pre.blocks.join("\n")}`; - process.stdout.write(`${TOOL_INDENT}${ansi(YELLOW, renderOpts())}⛔ ${tc.name} blocked: ${pre.blocks.join("; ")}${ansi(RESET, renderOpts())}\n`); + process.stdout.write(`${TOOL_INDENT}${ansi(YELLOW, renderOpts())}⛔ ${toolName} blocked: ${pre.blocks.join("; ")}${ansi(RESET, renderOpts())}\n`); // PreToolUse block is a failed tool outcome from the adaptive-routing // perspective: no tool actually ran, so count it toward errorStreak // so repeated denials can trigger recovery escalation. const signal = observeToolResult(adaptiveState, { - toolName: tc.name, + toolName, toolInput: tc.arguments, result: resultStr, }); @@ -947,19 +1037,19 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); turnSummary.wroteFiles = turnSummary.wroteFiles || signal.wroteFile; await maybeRouteForToolResult(); } else { - process.stdout.write(`${formatToolCall(tc.name, tc.arguments, renderOpts())}\n`); - let result; + process.stdout.write(`${formatToolCall(toolName, tc.arguments, renderOpts())}\n`); + let result: ToolHandlerResult; let handlerThrew = false; try { result = await handler(tc.arguments, { cwd: dir, - runtime: { engine, ctx, log: async (o) => appendEvent(session, { type: "log", ...o }) }, + runtime: { engine, ctx, log: async (o: Record) => appendEvent(session, { type: "log", ...o }) }, }); - } catch (err) { + } catch (err: unknown) { handlerThrew = true; - result = `Error executing ${tc.name}: ${err?.message || err}`; + result = `Error executing ${toolName}: ${toErrorMessage(err)}`; } - if (result && result.__finish) { + if (isFinishResult(result)) { finished = true; resultStr = `finish: ${result.summary}`; process.stdout.write(`${TOOL_INDENT}${ansi(GREEN, renderOpts())}✅ ${result.summary}${ansi(RESET, renderOpts())}\n`); @@ -968,7 +1058,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); process.stdout.write(`${formatToolResult(resultStr, renderOpts())}\n`); } const post = await engine.fire("PostToolUse", { - tool_name: tc.name, + tool_name: toolName, tool_input: tc.arguments, tool_response: resultStr, tool_use_id: tc.id, @@ -976,7 +1066,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); if (post.blocks.length) resultStr += `\n\n[hook feedback — address] ${post.blocks.join(" | ")}`; if (post.feedbacks.length) resultStr += `\n\n[hook note] ${post.feedbacks.join(" | ")}`; const signal = observeToolResult(adaptiveState, { - toolName: tc.name, + toolName, toolInput: tc.arguments, result: resultStr, handlerThrew, @@ -986,7 +1076,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); await maybeRouteForToolResult(); } ctx.push({ role: "tool", tool_call_id: tc.id, content: resultStr }); - await appendEvent(session, { type: "tool", tool_call_id: tc.id, name: tc.name, content: truncate(resultStr, 2000) }); + await appendEvent(session, { type: "tool", tool_call_id: tc.id, name: toolName, content: truncate(resultStr, 2000) }); if (finished) break; } if (finished) { @@ -1006,7 +1096,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); return turnSummary; }; - const handleLine = async (raw) => { + const handleLine = async (raw: string): Promise<"exit" | void> => { const input = raw.trim(); if (!input) return; if (input.startsWith("/")) { @@ -1034,7 +1124,7 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); // 10. REPL loop: prompt → read line → handle → repeat (sequential via the queue) let closed = false; - const prompt = () => { + const prompt = (): void => { // Interactive prompt placement (PR #26 Codex P2): // - stdout TTY: write to stdout as before. // - stdout piped but stdin still a TTY (`lazyglm | tee transcript`): @@ -1057,9 +1147,9 @@ Inline $skill invocations are also supported (e.g. $programming ...).`); try { const r = await handleLine(line); if (r === "exit") closed = true; - } catch (e) { + } catch (e: unknown) { closeStream(); - console.error(`\n❌ ${e?.message || e}`); + console.error(`\n❌ ${toErrorMessage(e)}`); } } closeStream(); diff --git a/src/scaffold/handoff.js b/src/scaffold/handoff.ts similarity index 86% rename from src/scaffold/handoff.js rename to src/scaffold/handoff.ts index 892c80f..5d6734b 100644 --- a/src/scaffold/handoff.js +++ b/src/scaffold/handoff.ts @@ -15,14 +15,25 @@ const HANDOFF_CANDIDATES = [ // larger than the configured budget. const MAX_HANDOFF_READ_BYTES = 16 * 1024; -export function discoverScaffold(cwd) { - const sources = []; +export interface ScaffoldDiscovery { + present: boolean; + sources: string[]; +} + +export interface HandoffText { + text: string; + source: string; + truncated: boolean; +} + +export function discoverScaffold(cwd: string): ScaffoldDiscovery { + const sources: string[] = []; if (existsSync(join(cwd, ".osc"))) sources.push(".osc/"); if (existsSync(join(cwd, "MISSION.md"))) sources.push("MISSION.md"); return { present: sources.length > 0, sources }; } -export async function readHandoffText(cwd, { maxChars = 600 } = {}) { +export async function readHandoffText(cwd: string, { maxChars = 600 }: { maxChars?: number } = {}): Promise { for (const candidate of HANDOFF_CANDIDATES) { const path = join(cwd, candidate.rel); if (!existsSync(path)) continue; @@ -48,7 +59,7 @@ export async function readHandoffText(cwd, { maxChars = 600 } = {}) { // huge handoff/mission record cannot exhaust memory or stall startup. const oversized = lstat.size > MAX_HANDOFF_READ_BYTES; const readBytes = oversized ? MAX_HANDOFF_READ_BYTES : lstat.size; - let raw; + let raw: string; try { const fh = await open(path, "r"); try { @@ -76,7 +87,7 @@ export async function readHandoffText(cwd, { maxChars = 600 } = {}) { return null; } -export function formatHandoffInject({ text, source, truncated }) { +export function formatHandoffInject({ text, source, truncated }: HandoffText): string { const suffix = truncated ? "\n\n[Open Scaffold handoff truncated before injection.]" : ""; return [ "OPEN SCAFFOLD HANDOFF CONTEXT", diff --git a/src/sessions.js b/src/sessions.ts similarity index 62% rename from src/sessions.js rename to src/sessions.ts index 51e1f5e..a025554 100644 --- a/src/sessions.js +++ b/src/sessions.ts @@ -7,27 +7,52 @@ import { appendFile, readFile, readdir, stat, mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { nowIso } from "./util.js"; +import type { Provider, SessionRecord } from "./types/index.js"; function home() { return process.env.LAZYGLM_HOME || join(process.env.HOME || "/tmp", ".lazyglm"); } -export function sessionsDir() { +export function sessionsDir(): string { return join(home(), "sessions"); } -export function newSessionId() { +export function newSessionId(): string { return `sess_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; } /** * Create a new session file with a header record. Returns { id, path, model, provider }. */ -export async function createSession({ id, model, provider, firstPrompt } = {}) { +export interface SessionInfo { + id: string; + path: string; + model?: string | null; + provider?: Provider | null; +} + +interface CreateSessionOptions { + id?: string; + model?: string | null; + provider?: Provider | null; + firstPrompt?: string | null; +} + +export interface ListedSession extends SessionInfo { + mtime: number; + firstPrompt?: string | null; + startedAt?: string | null; +} + +function asSessionRecord(value: unknown): SessionRecord { + return value && typeof value === "object" && !Array.isArray(value) ? value as SessionRecord : { t: nowIso(), type: "unknown" }; +} + +export async function createSession({ id, model, provider, firstPrompt }: CreateSessionOptions = {}): Promise { const sid = id || newSessionId(); const dir = sessionsDir(); await mkdir(dir, { recursive: true }); const path = join(dir, `${sid}.jsonl`); - const header = { t: nowIso(), type: "session", id: sid, model: model || null, provider: provider || null, firstPrompt: firstPrompt || null }; + const header: SessionRecord = { t: nowIso(), type: "session", id: sid, model: model || null, provider: provider || null, firstPrompt: firstPrompt || null }; await appendFile(path, JSON.stringify(header) + "\n", "utf8"); return { id: sid, path, model, provider }; } @@ -35,7 +60,7 @@ export async function createSession({ id, model, provider, firstPrompt } = {}) { /** * Append a turn event to a session file (best-effort; never throws). */ -export async function appendEvent(session, event) { +export async function appendEvent(session: Pick | null | undefined, event: Omit): Promise { if (!session?.path) return; try { await appendFile(session.path, JSON.stringify({ t: nowIso(), ...event }) + "\n", "utf8"); @@ -47,16 +72,16 @@ export async function appendEvent(session, event) { /** * List past sessions, most-recent first. Each entry: { id, path, mtime, model, provider, firstPrompt, startedAt }. */ -export async function listSessions() { +export async function listSessions(): Promise { const dir = sessionsDir(); if (!existsSync(dir)) return []; - let files = []; + let files: string[] = []; try { files = await readdir(dir); } catch { return []; } - const out = []; + const out: ListedSession[] = []; for (const f of files.filter((f) => f.endsWith(".jsonl"))) { const path = join(dir, f); let st; @@ -71,18 +96,18 @@ export async function listSessions() { return out.sort((a, b) => b.mtime - a.mtime); } -async function readSessionMeta(path) { +async function readSessionMeta(path: string): Promise> { try { const raw = await readFile(path, "utf8"); const first = raw.split("\n").find((l) => l.trim()); - const hdr = first ? JSON.parse(first) : {}; + const hdr = asSessionRecord(first ? JSON.parse(first) : {}); return { model: hdr.model, provider: hdr.provider, firstPrompt: hdr.firstPrompt, startedAt: hdr.t }; } catch { return {}; } } -export async function lastSession() { +export async function lastSession(): Promise { const list = await listSessions(); return list[0] || null; } @@ -90,10 +115,10 @@ export async function lastSession() { /** * Load all event records for a session id. Returns null if not found. */ -export async function loadSessionEvents(id) { +export async function loadSessionEvents(id: string): Promise { const path = join(sessionsDir(), `${id}.jsonl`); if (!existsSync(path)) return null; - let raw; + let raw: string; try { raw = await readFile(path, "utf8"); } catch { @@ -104,10 +129,10 @@ export async function loadSessionEvents(id) { .filter((l) => l.trim()) .map((l) => { try { - return JSON.parse(l); + return asSessionRecord(JSON.parse(l)); } catch { return null; } }) - .filter(Boolean); + .filter((record): record is SessionRecord => Boolean(record)); } diff --git a/src/status.js b/src/status.ts similarity index 86% rename from src/status.js rename to src/status.ts index dcbe315..9fa8d6a 100644 --- a/src/status.js +++ b/src/status.ts @@ -24,17 +24,38 @@ const DIM = "\x1b[2m"; const ANSI_RE = /\x1b\[[0-9;]*m/g; -function cleanSegment(value) { +interface TokenCounts { + prompt?: number; + completion?: number; + reasoning?: number; +} + +export interface StatusOptions { + sessionId?: string | null; + model?: string | null; + provider?: string | null; + role?: string | null; + reasoningEffort?: string | null; + tier?: string | null; + tierReason?: string | null; + cumulative?: TokenCounts | null; + lastTurn?: TokenCounts | null; + sessionElapsedMs?: number | null; + lastTurnMs?: number | null; + isTTY?: boolean; +} + +function cleanSegment(value: unknown): string { return String(value ?? "").replace(/[|\r\n]+/g, " ").replace(/\s+/g, " ").trim(); } -function truncateToChars(value, max = 96) { +function truncateToChars(value: unknown, max = 96): string { const text = cleanSegment(value); return text.length > max ? text.slice(0, Math.max(0, max - 1)) + "…" : text; } /** Human-readable duration for the TTY line (e.g. "2m13s", "4.2s", "0ms"). */ -function humanizeMs(ms) { +function humanizeMs(ms: number | null | undefined): string { const n = Number(ms) || 0; if (n < 1000) return `${Math.max(0, Math.round(n))}ms`; const s = n / 1000; @@ -78,13 +99,13 @@ export function renderStatus({ sessionElapsedMs, lastTurnMs, isTTY, -} = {}) { +}: StatusOptions = {}): string { const sid = sessionId ?? "?"; const m = model ?? "?"; const p = provider ?? "?"; const r = role ?? "default"; const effort = reasoningEffort ?? "high"; - const c = cumulative && typeof cumulative === "object" ? cumulative : { prompt: 0, completion: 0, reasoning: 0 }; + const c: TokenCounts = cumulative && typeof cumulative === "object" ? cumulative : { prompt: 0, completion: 0, reasoning: 0 }; const lt = lastTurn && typeof lastTurn === "object" ? lastTurn : null; const sElapsed = humanizeMs(sessionElapsedMs); const tElapsed = lastTurnMs != null ? humanizeMs(lastTurnMs) : "—"; diff --git a/src/ulw.js b/src/ulw.ts similarity index 77% rename from src/ulw.js rename to src/ulw.ts index dd09855..ac5deb0 100644 --- a/src/ulw.js +++ b/src/ulw.ts @@ -10,16 +10,62 @@ import { resolvePath } from "./util.js"; import { abortReason, boundedTimeoutMs, composeAbortSignals, isDeadlineError } from "./agent/deadline.js"; import { runAgent } from "./agent/runtime.js"; import { loadPlugins } from "./plugins/index.js"; +import type { Deadline } from "./agent/deadline.js"; +import type { HookPlugin, PermissionMode, ProviderConfig, RoleName, RunAgentResult } from "./types/index.js"; const execP = promisify(exec); +export interface VerifyFinishOptions { + summary?: string | null; + cwd: string; + verifyCommand?: string | null; + filesWritten?: string[] | null; + deadline?: Deadline | null; + signal?: AbortSignal | null; +} + +export interface VerifyFinishResult { + pass: boolean; + reason: string; + evidence: string; +} + +export interface RunUltraworkOptions { + task: string; + cwd: string; + model?: string; + role?: RoleName; + config?: ProviderConfig; + completionPromise?: string; + verifyCommand?: string; + maxIterations?: number; + maxTurns?: number; + budget?: number; + reasoningBudget?: number; + onEvent?: (event: Record) => void; + permissionMode?: PermissionMode; + failOnToolBlock?: boolean; + plugins?: HookPlugin[]; + deadline?: Deadline; + signal?: AbortSignal; +} + +function asError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +function errorRecord(err: unknown): { stdout?: unknown; stderr?: unknown; code?: unknown; message?: unknown } { + return err && typeof err === "object" ? err as { stdout?: unknown; stderr?: unknown; code?: unknown; message?: unknown } : { message: String(err) }; +} + /** * Verify a finish claim: files the agent actually wrote still exist + an * optional verifyCommand passes. * @returns {{pass:boolean, reason:string, evidence:string}} */ -export async function verifyFinish({ summary, cwd, verifyCommand, filesWritten, deadline, signal }) { - const evidence = []; +export async function verifyFinish({ summary, cwd, verifyCommand, filesWritten, deadline, signal }: VerifyFinishOptions): Promise { + void summary; + const evidence: string[] = []; deadline?.throwIfExpired?.(); const runAbort = composeAbortSignals([deadline?.signal, signal]); const runSignal = runAbort.signal; @@ -27,7 +73,7 @@ export async function verifyFinish({ summary, cwd, verifyCommand, filesWritten, // 1. files the agent wrote via tools must still exist (robust: tracked, not // regex-extracted from prose, which would false-match CDN specifiers). const written = Array.isArray(filesWritten) ? filesWritten : []; - const missingWritten = []; + const missingWritten: string[] = []; for (const rel of written) { try { if (!existsSync(resolvePath(rel, cwd))) missingWritten.push(rel); @@ -52,9 +98,10 @@ export async function verifyFinish({ summary, cwd, verifyCommand, filesWritten, evidence.push(`verify command exited 0:\n${out}`); return { pass: true, reason: "verify command passed", evidence: evidence.join("\n") }; } catch (err) { - if (isDeadlineError(err) || runSignal?.aborted) throw abortReason(runSignal, err); - const out = [err.stdout, err.stderr, err.message].filter(Boolean).join("\n").trim().slice(-600); - return { pass: false, reason: `verify command failed (exit ${err.code ?? "?"}):\n${out}`, evidence: evidence.join("\n") }; + if (isDeadlineError(err) || runSignal?.aborted) throw abortReason(runSignal, asError(err)); + const record = errorRecord(err); + const out = [record.stdout, record.stderr, record.message].filter(Boolean).join("\n").trim().slice(-600); + return { pass: false, reason: `verify command failed (exit ${record.code ?? "?"}):\n${out}`, evidence: evidence.join("\n") }; } finally { runAbort.cancel(); } @@ -88,16 +135,17 @@ export async function runUltrawork({ plugins, deadline, signal, -}) { +}: RunUltraworkOptions) { const promise = completionPromise || "the task is fully implemented and builds cleanly."; let currentTask = `${task}\n\n[ULTRAWORK] Completion promise: ${promise}`; - const history = []; + const history: RunAgentResult[] = []; for (let i = 1; i <= maxIterations; i++) { try { deadline?.throwIfExpired?.(); } catch (err) { - return { verified: false, iterations: i - 1, verdict: { pass: false, reason: err?.message || String(err) }, history, finishReason: "timeout", errorMessage: err?.message || String(err) }; + const message = asError(err).message; + return { verified: false, iterations: i - 1, verdict: { pass: false, reason: message }, history, finishReason: "timeout", errorMessage: message }; } onEvent({ type: "ultrawork_iteration", iteration: i, max: maxIterations }); const res = await runAgent({ @@ -149,7 +197,7 @@ export async function runUltrawork({ verdict = await verifyFinish({ summary: res.finishSummary, cwd, verifyCommand, filesWritten: res.filesWritten, deadline, signal }); } catch (err) { if (isDeadlineError(err) || deadline?.signal?.aborted || signal?.aborted) { - const message = abortReason(deadline?.signal || signal, err).message; + const message = abortReason(deadline?.signal || signal, asError(err)).message; return { verified: false, iterations: i, verdict: { pass: false, reason: message }, history, finishReason: "timeout", errorMessage: message }; } throw err; diff --git a/src/update.js b/src/update.ts similarity index 77% rename from src/update.js rename to src/update.ts index 4adf03c..a79137f 100644 --- a/src/update.js +++ b/src/update.ts @@ -10,18 +10,46 @@ import { readJson } from "./util.js"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +type UpdateStatus = "behind" | "ahead" | "equal" | "error"; + +export interface UpdateResult { + status: UpdateStatus; + local: string; + remote: string | null; + detail?: string; + exitCode: 0 | 1 | 2; + updated?: boolean; +} + +interface CheckUpdateOptions { + fetchRemote?: () => string | Promise; + readLocal?: () => string | Promise; +} + +interface SelfUpdateOptions extends CheckUpdateOptions { + force?: boolean; + installer?: () => void; + prompt?: (message: string) => string | Promise; + isInteractive?: () => boolean; + stdout?: { write(text: string): unknown }; +} + +function errorMessage(err: unknown): string { + return err && typeof err === "object" && "message" in err ? String((err as { message?: unknown }).message) : String(err); +} + // Strip surrounding quotes and whitespace. npm honours NPM_CONFIG_JSON / --json // globally; when set, `npm view version` returns a JSON-quoted string such // as "1.2.3" instead of the bare 1.2.3. Feeding that to compareSemver makes the // quoted major parse as NaN and inverts/equalises the result, silently hiding // available updates. (PR #25 Codex review P2, thread src/update.js:34.) -export function normalizeVersion(v) { +export function normalizeVersion(v: unknown): string { return String(v ?? "").trim().replace(/^["']|["']$/g, ""); } // Pure: compare two x.y.z versions. Returns -1 if a < b, 0 if equal, 1 if a > b. // Only the first three numeric components are considered (no prerelease/ranges). -export function compareSemver(a, b) { +export function compareSemver(a: unknown, b: unknown): -1 | 0 | 1 { const pa = String(a ?? "0.0.0").split("."); const pb = String(b ?? "0.0.0").split("."); for (let i = 0; i < 3; i++) { @@ -33,19 +61,19 @@ export function compareSemver(a, b) { return 0; } -export async function readLocalVersion() { - const pkg = await readJson(join(ROOT, "package.json"), {}); - return pkg.version || "0.0.0"; +export async function readLocalVersion(): Promise { + const pkg = await readJson<{ version?: string }>(join(ROOT, "package.json"), {}); + return pkg?.version || "0.0.0"; } // Real remote fetch via the npm registry. Overridable in tests via seams. // `--no-json` neutralises a global NPM_CONFIG_JSON/--json setting, which would // otherwise wrap the version in double quotes and break compareSemver. -export function fetchRemoteVersion() { +export function fetchRemoteVersion(): string { return execSync("npm view lazyglm version --no-json", { stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } -export function describeUpdate(result) { +export function describeUpdate(result: UpdateResult): string { if (result.status === "behind") return `lazyglm ${result.local} is behind ${result.remote} — update available.`; if (result.status === "ahead") return `lazyglm ${result.local} is ahead of ${result.remote} — running newer than the registry.`; if (result.status === "equal") return `lazyglm ${result.local} is up to date.`; @@ -55,13 +83,13 @@ export function describeUpdate(result) { // Compare local vs remote. Never touches npm when fetchRemote is injected. // exitCode: 0 when local >= remote, 1 when behind (update available), // 2 on fetch/registry error. -export async function checkUpdate({ fetchRemote = fetchRemoteVersion, readLocal = readLocalVersion } = {}) { +export async function checkUpdate({ fetchRemote = fetchRemoteVersion, readLocal = readLocalVersion }: CheckUpdateOptions = {}): Promise { const local = await readLocal(); let remote; try { remote = await fetchRemote(); } catch (e) { - const detail = (e && e.message) ? e.message : String(e); + const detail = errorMessage(e); return { status: "error", detail, local, remote: null, exitCode: 2 }; } if (!remote) { @@ -75,11 +103,11 @@ export async function checkUpdate({ fetchRemote = fetchRemoteVersion, readLocal return { status: "ahead", local, remote, exitCode: 0 }; } -function defaultInstaller() { +function defaultInstaller(): void { execSync("npm install -g lazyglm@latest", { stdio: ["ignore", "pipe", "pipe"] }); } -async function defaultPrompt(message) { +async function defaultPrompt(message: string): Promise { const rl = readline.createInterface({ input: defaultInput, output: defaultOutput }); try { return (await rl.question(message)).trim(); @@ -98,7 +126,7 @@ export async function selfUpdate({ prompt = defaultPrompt, isInteractive = () => defaultInput.isTTY === true && defaultOutput.isTTY === true, stdout = process.stdout, -} = {}) { +}: SelfUpdateOptions = {}): Promise { const result = await checkUpdate({ fetchRemote, readLocal }); stdout.write(describeUpdate(result) + "\n"); @@ -131,7 +159,7 @@ export async function selfUpdate({ stdout.write(`Updated lazyglm to ${result.remote}.\n`); return { ...result, updated: true, exitCode: 0 }; } catch (e) { - const detail = (e && e.message) ? e.message : String(e); + const detail = errorMessage(e); stdout.write(`Update failed: ${detail}\n`); return { ...result, updated: false, detail, exitCode: 2 }; } diff --git a/test/banner.test.mjs b/test/banner.test.mjs index cde4dba..938e000 100644 --- a/test/banner.test.mjs +++ b/test/banner.test.mjs @@ -3,10 +3,10 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; -import { renderBanner, WORDMARK } from "../src/banner.js"; +import { renderBanner, WORDMARK } from "../dist/banner.js"; const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); -const SRC = join(ROOT, "src", "banner.js"); +const SRC = join(ROOT, "dist", "banner.js"); const base = { model: "glm-5.2", provider: "zai", cwd: "/tmp/demo" }; const ANSI_RE = /\x1b\[[0-9;]*m/g; diff --git a/test/status.test.mjs b/test/status.test.mjs index de4c570..421c7e9 100644 --- a/test/status.test.mjs +++ b/test/status.test.mjs @@ -1,11 +1,11 @@ -// Unit tests for src/status.js — the on-demand `/status` renderer. +// Unit tests for the on-demand `/status` renderer. // // Mirrors the purity contract of banner.test.mjs: renderStatus() must be a pure // function (no process reads, no stdout writes), return a string, emit ANSI only // under TTY, and stay a single zero-ANSI machine-readable line under non-TTY. import { test } from "node:test"; import assert from "node:assert/strict"; -import { renderStatus, ANSI_RE } from "../src/status.js"; +import { renderStatus, ANSI_RE } from "../dist/status.js"; const base = { sessionId: "sess_abc123", diff --git a/test/thinking.test.mjs b/test/thinking.test.mjs index 43a2895..00c4062 100644 --- a/test/thinking.test.mjs +++ b/test/thinking.test.mjs @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { thinkingControlForRequest, supportsReasoningEffort } from "../src/agent/thinking.js"; +import { thinkingControlForRequest, supportsReasoningEffort } from "../dist/agent/thinking.js"; test("thinkingControlForRequest maps only z.ai low effort to disabled", () => { assert.deepEqual( @@ -54,4 +54,3 @@ test("supportsReasoningEffort: false for unparseable / empty names", () => { assert.equal(supportsReasoningEffort(undefined), false); assert.equal(supportsReasoningEffort(null), false); }); -