diff --git a/docs/plans/state.json b/docs/plans/state.json index 5e29544..b5ae85e 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -2649,7 +2649,7 @@ "test_status": "pnpm test:run src/gateway/server.test.ts src/tools/executor.test.ts src/backends/native/orchestrator.test.ts src/daemon/routing.test.ts + pnpm typecheck + pnpm lint passing (0 errors, warnings remain)" }, "audit-followup-lint-warning-reduction-pass-1": { - "status": "in_progress", + "status": "completed", "date": "2026-02-16", "updated": "2026-02-16", "summary": "Continued stage-2 lint warning reduction with hotspot-focused cleanup in `gateway/handlers/handlers.test.ts`, `daemon/routing.test.ts`, `frontends/tui/minimal.test.ts`, `gateway/tailscale.test.ts`, `automation/webhooks.test.ts`, `automation/cron.test.ts`, `automation/heartbeat.test.ts`, `frontends/tui/minimal.login.test.ts`, `daemon/clientFactory.test.ts`, `gateway/handlers/services.test.ts`, `models/local/llamacpp.test.ts`, `models/local/ollama.test.ts`, and `tools/builtin/image-analyze.test.ts`. Replaced broad `any` casts with typed helper casts/unknown-path accessors and removed non-null assertions in high-warning tests. Warning count reduced from 466 to 203 (263 warnings burned down) with lint/test suites still green.", @@ -2675,6 +2675,31 @@ "docs/plans/analysis/2026-02-16-codebase-audit-report.md" ], "test_status": "pnpm test:run src/channels/utils.test.ts src/channels/discord/adapter.test.ts src/channels/slack/adapter.test.ts src/channels/whatsapp/adapter.test.ts src/daemon/routing.test.ts src/gateway/handlers/handlers.test.ts src/frontends/tui/minimal.test.ts src/gateway/tailscale.test.ts src/automation/webhooks.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/frontends/tui/minimal.login.test.ts src/daemon/clientFactory.test.ts src/gateway/handlers/services.test.ts src/models/local/llamacpp.test.ts src/models/local/ollama.test.ts src/tools/builtin/image-analyze.test.ts + pnpm lint passing (0 errors, 203 warnings)" + }, + "audit-followup-lint-warning-reduction-pass-2": { + "status": "in_progress", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Continued warning burn-down on current hotspots by removing non-null assertions and broad `any` usage, tightening adapter/client typing, and cleaning unused imports/params across orchestrator/model/channel/gateway/TUI/doctor files and selected tests. Warning count reduced from 203 to 115 (88 warnings burned down) while keeping lint at 0 errors.", + "files_modified": [ + "src/backends/native/agent.ts", + "src/backends/native/orchestrator.test.ts", + "src/channels/discord/adapter.ts", + "src/channels/whatsapp/adapter.ts", + "src/cli/doctor.ts", + "src/cli/setup/providers.test.ts", + "src/frontends/tui/minimal.ts", + "src/gateway/server.ts", + "src/gateway/session-bridge.test.ts", + "src/memory/hybrid-search.test.ts", + "src/models/anthropic.ts", + "src/models/local/ollama.ts", + "src/models/openai.oauth.test.ts", + "src/models/openai.ts", + "src/models/router.test.ts", + "src/skills/installer.test.ts" + ], + "test_status": "pnpm exec eslint on edited files + pnpm lint passing (0 errors, 115 warnings)" } }, "overall_progress": { diff --git a/src/backends/native/agent.ts b/src/backends/native/agent.ts index cf95b41..0490d99 100644 --- a/src/backends/native/agent.ts +++ b/src/backends/native/agent.ts @@ -1,4 +1,4 @@ -import type { ModelClient, Message, ChatRequest, ChatResponse, ModelToolCall, TokenUsage } from '../../models/types.js'; +import type { ModelClient, Message, ChatRequest, ChatResponse, TokenUsage } from '../../models/types.js'; import type { ModelRouter, ModelTier } from '../../models/router.js'; import type { Session } from '../../session/index.js'; import type { ToolRegistry } from '../../tools/registry.js'; @@ -8,7 +8,7 @@ import type { ToolPolicyContext } from '../../tools/policy.js'; import { auditLogger } from '../../audit/index.js'; import type { Attachment } from '../../channels/types.js'; import type { OutboundAttachmentCollector } from './attachments.js'; -import { buildUserMessage, getMessageText } from '../../models/media.js'; +import { buildUserMessage } from '../../models/media.js'; export interface ToolUseEvent { type: 'start' | 'end'; @@ -142,7 +142,12 @@ export class NativeAgent { } private async toolLoop(): Promise { - const tools = this.toolRegistry!.filteredToAnthropicFormat(this._toolPolicyContext); + const toolRegistry = this.toolRegistry; + const toolExecutor = this.toolExecutor; + if (!toolRegistry || !toolExecutor) { + throw new Error('Tool loop requires tool registry and executor'); + } + const tools = toolRegistry.filteredToAnthropicFormat(this._toolPolicyContext); // Track whether untrusted content (web/fetched/tool output) has been introduced // during this run. Used to harden against prompt injection. @@ -218,7 +223,10 @@ export class NativeAgent { } // Safe to assert non-null — wantsToolUse guarantees toolCalls exists and is non-empty - const toolCalls = response.toolCalls!; + const toolCalls = response.toolCalls; + if (!toolCalls || toolCalls.length === 0) { + continue; + } // Check for repeated tool calls — build a fingerprint from tool names + args const fingerprint = toolCalls @@ -264,7 +272,7 @@ export class NativeAgent { for (const tc of toolCalls) { this.throwIfCancelled(); - const internalName = this.toolRegistry!.getByApiName(tc.name)?.name ?? tc.name; + const internalName = toolRegistry.getByApiName(tc.name)?.name ?? tc.name; this.onToolUse?.({ type: 'start', tool: internalName, args: tc.args }); let elevationUntilMs: number | undefined; @@ -311,7 +319,7 @@ export class NativeAgent { } : undefined; - const result = await this.toolExecutor!.execute(internalName, tc.args, perCallContext); + const result = await toolExecutor.execute(internalName, tc.args, perCallContext); this.onToolUse?.({ type: 'end', tool: internalName, result }); diff --git a/src/backends/native/orchestrator.test.ts b/src/backends/native/orchestrator.test.ts index 40b05c6..af7c065 100644 --- a/src/backends/native/orchestrator.test.ts +++ b/src/backends/native/orchestrator.test.ts @@ -5,11 +5,13 @@ import type { ChatResponse, ModelClient } from '../../models/types.js'; import { ToolRegistry, ToolExecutor } from '../../tools/index.js'; import { HookEngine } from '../../hooks/engine.js'; import { MemoryStore } from '../../memory/store.js'; +import type { Session } from '../../session/index.js'; import { mkdtempSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { auditLogger, initAuditLogger } from '../../audit/index.js'; import type { AuditLogger } from '../../audit/index.js'; +import type { Message } from '../../models/types.js'; describe('AgentOrchestrator', () => { let mockDefaultClient: ModelClient; @@ -468,7 +470,7 @@ describe('AgentOrchestrator', () => { fallbackChain: [], }); - const history: any[] = [ + const history: Message[] = [ { role: 'user', content: 'u1' }, { role: 'assistant', content: 'a1' }, { role: 'user', content: 'u2' }, @@ -476,19 +478,19 @@ describe('AgentOrchestrator', () => { { role: 'user', content: 'u3' }, { role: 'assistant', content: 'a3' }, ]; - const session = { + const session: Session = { id: 'session-compact-audit', - addMessage: vi.fn((m: any) => { history.push(m); }), + addMessage: vi.fn((m: Message) => { history.push(m); }), getHistory: vi.fn(() => [...history]), clear: vi.fn(() => { history.length = 0; }), - replaceHistory: vi.fn((msgs: any[]) => { + replaceHistory: vi.fn((msgs: Message[]) => { history.length = 0; history.push(...msgs); }), getConfig: vi.fn(() => undefined), setConfig: vi.fn(), deleteConfig: vi.fn(), - } as any; + }; const sessionCompact = vi.fn(); const previousAuditLogger = auditLogger; @@ -717,27 +719,27 @@ describe('AgentOrchestrator', () => { }); // Minimal Session stub that supports rollback via replaceHistory(). - const history: any[] = []; - const session = { + const history: Message[] = []; + const session: Session = { id: 'test', - addMessage: vi.fn((m: any) => { history.push(m); }), + addMessage: vi.fn((m: Message) => { history.push(m); }), getHistory: vi.fn(() => [...history]), clear: vi.fn(() => { history.length = 0; }), - replaceHistory: vi.fn((msgs: any[]) => { + replaceHistory: vi.fn((msgs: Message[]) => { history.length = 0; history.push(...msgs); }), getConfig: vi.fn(() => undefined), setConfig: vi.fn(), deleteConfig: vi.fn(), - } as any; + }; const registry = new ToolRegistry(); registry.register({ name: 'test.echo', description: 'echo', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, - execute: async (args: any) => ({ success: true, output: String(args.text ?? '') }), + execute: async (args: { text?: string }) => ({ success: true, output: String(args.text ?? '') }), }); const hooks = new HookEngine({ confirm: [], log: [], silent: [] }); diff --git a/src/channels/discord/adapter.ts b/src/channels/discord/adapter.ts index e9efafe..f9fbe56 100644 --- a/src/channels/discord/adapter.ts +++ b/src/channels/discord/adapter.ts @@ -85,7 +85,7 @@ export class DiscordAdapter implements ChannelAdapter { async connect(): Promise { this._status = 'connecting'; - this.client = new Client({ + const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, @@ -93,23 +93,24 @@ export class DiscordAdapter implements ChannelAdapter { GatewayIntentBits.DirectMessages, ], }); + this.client = client; // ── Ready handler — resolve connect() when the bot is online ── const readyPromise = new Promise((resolve) => { - this.client!.on(Events.ClientReady, () => { - console.log(`Discord bot ready as ${this.client!.user?.tag}`); + client.on(Events.ClientReady, () => { + console.log(`Discord bot ready as ${client.user?.tag}`); this._status = 'connected'; resolve(); }); }); // ── Message handler — route inbound messages ── - this.client.on(Events.MessageCreate, (message: DiscordMessage) => { + client.on(Events.MessageCreate, (message: DiscordMessage) => { void this.handleMessage(message); }); // Log in and wait for the ready event - await this.client.login(this.config.botToken); + await client.login(this.config.botToken); await readyPromise; } @@ -162,8 +163,11 @@ export class DiscordAdapter implements ChannelAdapter { name: attachment.filename ?? 'attachment', }); } + if (!attachment.url) { + throw new Error('Attachment must include data or url'); + } // URL-based attachment - return new AttachmentBuilder(attachment.url!, { + return new AttachmentBuilder(attachment.url, { name: attachment.filename ?? 'attachment', }); } @@ -180,9 +184,8 @@ export class DiscordAdapter implements ChannelAdapter { // ── Guild/channel filtering ── if (!isDM) { // Check allowed guild IDs - if ( - !isAllowedByAllowlist(message.guild!.id, this.config.allowedGuildIds) - ) { + const guildId = message.guild?.id; + if (!guildId || !isAllowedByAllowlist(guildId, this.config.allowedGuildIds)) { return; } @@ -223,7 +226,7 @@ export class DiscordAdapter implements ChannelAdapter { // Send typing indicator (lasts 10 seconds, no need for interval) try { if ('sendTyping' in message.channel) { - (message.channel as any).sendTyping(); + await (message.channel as { sendTyping: () => Promise }).sendTyping(); } } catch { /* ignore typing errors */ } diff --git a/src/channels/whatsapp/adapter.ts b/src/channels/whatsapp/adapter.ts index 5271131..d84d756 100644 --- a/src/channels/whatsapp/adapter.ts +++ b/src/channels/whatsapp/adapter.ts @@ -57,6 +57,10 @@ interface WhatsAppMessage { type?: string; /** Download the media attached to this message. */ downloadMedia?: () => Promise<{ mimetype: string; data: string; filename?: string } | null>; + /** Chat handle for typing indicator. */ + getChat?: () => Promise<{ sendStateTyping: () => Promise }>; + /** Mentioned user IDs in message metadata. */ + mentionedIds?: string[]; } /** @@ -110,34 +114,41 @@ export class WhatsAppAdapter implements ChannelAdapter { args: puppeteerArgs, }, }); + const client = this.client; + if (!client) { + throw new Error('WhatsApp client initialization failed'); + } // Promise that resolves on 'ready' or rejects on 'auth_failure' const readyPromise = new Promise((resolve, reject) => { - this.client!.on('ready', () => { + client.on('ready', () => { console.log('WhatsApp bot connected'); this._status = 'connected'; // Capture bot's own JID for mention detection - this.botId = (this.client as any)?.info?.wid?._serialized; + const clientInfo = client as InstanceType & { + info?: { wid?: { _serialized?: string } }; + }; + this.botId = clientInfo.info?.wid?._serialized; resolve(); }); - this.client!.on('auth_failure', (msg: string) => { + client.on('auth_failure', (msg: string) => { this._status = 'error'; reject(new Error(`WhatsApp auth failure: ${msg}`)); }); - this.client!.on('qr', (qr: string) => { + client.on('qr', (qr: string) => { console.log('WhatsApp QR code received. Scan with your phone:'); console.log(qr); }); }); // Register message event handler - this.client.on('message', (message: unknown) => { + client.on('message', (message: unknown) => { this.handleMessage(message as WhatsAppMessage); }); - await this.client.initialize(); + await client.initialize(); await readyPromise; } catch (error) { this._status = 'error'; @@ -234,7 +245,7 @@ export class WhatsAppAdapter implements ChannelAdapter { requireMention: this.config.requireMention, defaultRequireMention: true, mentionsBot: message.body?.includes(`@${this.botId.replace(/@c\.us$/, '')}`) || - (message as unknown as { mentionedIds?: string[] }).mentionedIds?.some((id) => id === this.botId) === true, + message.mentionedIds?.some((id) => id === this.botId) === true, })) { // WhatsApp mentions use @phone_number format in body // Also check for mentions in the message mentionedIds @@ -269,8 +280,8 @@ export class WhatsAppAdapter implements ChannelAdapter { // Send typing indicator try { - const chat = await (message as any).getChat(); - await chat.sendStateTyping(); + const chat = await message.getChat?.(); + await chat?.sendStateTyping(); } catch { /* ignore typing errors */ } // Strip bot mention from message body for group messages @@ -286,7 +297,7 @@ export class WhatsAppAdapter implements ChannelAdapter { const attachments: Attachment[] = []; if (message.hasMedia) { try { - const media = await (message as any).downloadMedia(); + const media = await message.downloadMedia?.(); if (media && typeof media.mimetype === 'string') { const mimeType = media.mimetype; const isAudio = mimeType.startsWith('audio/'); diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 1c2233c..7ce9d21 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -20,6 +20,11 @@ export interface DoctorContext { } type Check = (ctx: DoctorContext) => Promise; +type UnknownRecord = Record; + +const asRecord = (value: unknown): UnknownRecord | undefined => ( + value && typeof value === 'object' ? value as UnknownRecord : undefined +); const checkConfigExists: Check = async (ctx) => { if (existsSync(ctx.configPath)) { @@ -78,8 +83,9 @@ const checkDeprecatedConfigKeys: Check = async (ctx) => { try { const raw = readFileSync(ctx.configPath, 'utf-8'); - const parsed = parse(raw) as any; - const tailscaleOnly = Boolean(parsed?.server && typeof parsed.server === 'object' && 'tailscale_only' in parsed.server); + const parsed = asRecord(parse(raw)); + const server = asRecord(parsed?.server); + const tailscaleOnly = Boolean(server && 'tailscale_only' in server); if (tailscaleOnly) { return { @@ -198,11 +204,11 @@ const checkModelConnectivity: Check = async (ctx) => { return true; } const oauth = o.oauth; + const oauthRecord = asRecord(oauth); return Boolean( - oauth - && typeof oauth === 'object' - && typeof (oauth as any).access_token === 'string' - && typeof (oauth as any).refresh_token === 'string', + oauthRecord + && typeof oauthRecord.access_token === 'string' + && typeof oauthRecord.refresh_token === 'string', ); }; @@ -213,22 +219,21 @@ const checkModelConnectivity: Check = async (ctx) => { } const o = openai as Record; const apiKey = o.api_key; + const apiKeyRecord = asRecord(apiKey); return Boolean( - apiKey - && typeof apiKey === 'object' - && typeof (apiKey as any).api_key === 'string' - && (apiKey as any).api_key.length > 0, + typeof apiKeyRecord?.api_key === 'string' + && apiKeyRecord.api_key.length > 0, ); }; const storeAnthropicApiKeyPresent = (): boolean => { - const anthropic = store.anthropic as any; - return Boolean(anthropic && typeof anthropic.api_key === 'string' && anthropic.api_key.length > 0); + const anthropic = asRecord(store.anthropic); + return Boolean(typeof anthropic?.api_key === 'string' && anthropic.api_key.length > 0); }; const storeAnthropicAuthTokenPresent = (): boolean => { - const anthropic = store.anthropic as any; - return Boolean(anthropic && typeof anthropic.auth_token === 'string' && anthropic.auth_token.length > 0); + const anthropic = asRecord(store.anthropic); + return Boolean(typeof anthropic?.auth_token === 'string' && anthropic.auth_token.length > 0); }; const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => { @@ -318,7 +323,7 @@ const checkModelConnectivity: Check = async (ctx) => { const envVar = envVarMap[provider]; const sources = { config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, - env: Boolean(envVar && process.env[envVar] && process.env[envVar]!.length > 0), + env: Boolean(envVar && typeof process.env[envVar] === 'string' && process.env[envVar].length > 0), store: false, }; const ok = sources.config || sources.env; diff --git a/src/cli/setup/providers.test.ts b/src/cli/setup/providers.test.ts index b0e894c..c94b65f 100644 --- a/src/cli/setup/providers.test.ts +++ b/src/cli/setup/providers.test.ts @@ -1,16 +1,14 @@ import { describe, it, expect } from 'vitest'; -import { createInterface } from 'readline/promises'; -import { EventEmitter } from 'events'; +import type { Interface as ReadlineInterface } from 'readline/promises'; import { createPrompter } from './prompts.js'; import { ConfigBuilder } from './config.js'; import { setupProviders } from './providers.js'; function mockReadline(inputs: string[]) { let questionIdx = 0; - const emitter = new EventEmitter(); return { - async question(query: string) { + async question(_query: string) { const answer = inputs[questionIdx++]; return answer ?? ''; }, @@ -26,7 +24,7 @@ function mockReadline(inputs: string[]) { async next() { return { done: true }; }, - } as any; + } as unknown as ReadlineInterface; } describe('setupProviders', () => { diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 014dd49..af0d56e 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -1,7 +1,7 @@ import * as readline from 'node:readline'; import type { ManagedSession } from '../../session/index.js'; import type { ModelClient, TokenUsage } from '../../models/types.js'; -import type { ModelRouter, ModelTier } from '../../models/router.js'; +import type { ModelRouter } from '../../models/router.js'; import type { NativeAgent } from '../../backends/native/agent.js'; import { parseCommand, getHelpText, resolveModelAlias, getCommandCompletions, getCommandTooltip, type Command } from './commands.js'; import { renderMarkdown } from './markdown.js'; @@ -406,13 +406,13 @@ export class MinimalTui { } }); }); - } catch (error) { + } catch { // Service might not exist or already stopped, ignore console.log(`${colors.gray}Note: ${provider} service not managed by systemd${colors.reset}\n`); } } - private async startBackend(provider: string, config: ModelConfig): Promise { + private async startBackend(provider: string, _config: ModelConfig): Promise { try { const { exec } = await import('child_process'); let serviceName: string; @@ -454,7 +454,10 @@ export class MinimalTui { const promptHidden = async (question: string): Promise => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); - const rlAny = rl as any; + const rlAny = rl as readline.Interface & { + stdoutMuted?: boolean; + _writeToOutput?: (s: string) => void; + }; rlAny.stdoutMuted = true; rlAny._writeToOutput = (s: string) => { if (!rlAny.stdoutMuted) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 20acb3d..cb6d110 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -135,6 +135,8 @@ export class GatewayServer { } private registerHandlers(): void { + const channelRegistry = this.config.channelRegistry; + const runtimeConfig = this.config.config; const systemHandlers = createSystemHandlers({ startTime: this.startTime, version: this.config.version ?? '0.1.0', @@ -142,14 +144,14 @@ export class GatewayServer { getToolCount: () => this.config.toolRegistry.list().length, getConnectionCount: () => this.sessionBridge.connectionCount, restart: this.config.restart, - getChannels: this.config.channelRegistry - ? () => this.config.channelRegistry!.list().map(a => ({ name: a.name, status: a.status })) + getChannels: channelRegistry + ? () => channelRegistry.list().map(a => ({ name: a.name, status: a.status })) : undefined, - getServices: this.config.config && this.config.channelRegistry - ? () => discoverServices(this.config.config!, this.config.channelRegistry!) + getServices: runtimeConfig && channelRegistry + ? () => discoverServices(runtimeConfig, channelRegistry) : undefined, - getPresence: this.config.channelRegistry - ? (opts) => this.config.channelRegistry!.getPresence(opts) + getPresence: channelRegistry + ? (opts) => channelRegistry.getPresence(opts) : undefined, getUsage: () => ({ totalSessions: this.config.sessionManager.listSessions().length, @@ -307,7 +309,7 @@ export class GatewayServer { }); } - private handleConnection(ws: WebSocket, identity?: string): void { + private handleConnection(ws: WebSocket, _identity?: string): void { // Gateway lock — reject if another client is already connected if (this.config.lock && this.connectionMap.size > 0) { ws.close(4003, 'Gateway locked — another client is already connected'); diff --git a/src/gateway/session-bridge.test.ts b/src/gateway/session-bridge.test.ts index 1f342c2..92d5d45 100644 --- a/src/gateway/session-bridge.test.ts +++ b/src/gateway/session-bridge.test.ts @@ -124,7 +124,10 @@ describe('SessionBridge', () => { const bridge = createBridge(); bridge.connect('conn-1'); const agent = bridge.getAgent('conn-1'); - const cancelSpy = vi.spyOn(agent!, 'cancel'); + if (!agent) { + throw new Error('Expected agent for conn-1'); + } + const cancelSpy = vi.spyOn(agent, 'cancel'); bridge.setBusy('conn-1', true); expect(bridge.cancel('conn-1')).toBe(true); @@ -193,7 +196,7 @@ describe('SessionBridge', () => { }, compaction: { enabled: false }, models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, - } as any); + } as unknown as SessionBridgeConfig['config']); bridge.connect('conn-tier'); const agent = bridge.getAgent('conn-tier'); @@ -201,7 +204,7 @@ describe('SessionBridge', () => { }); it('keeps different sessions isolated by persisted model tier', () => { - const sessionById: Record = {}; + const sessionById: Record = {}; const localSessionManager = { ...mockSessionManager, getSession: vi.fn((frontend: string, sessionId: string) => { @@ -238,7 +241,7 @@ describe('SessionBridge', () => { }, compaction: { enabled: false }, models: { default: { provider: 'anthropic', model: 'claude-3-haiku' } }, - } as any, + } as unknown as SessionBridgeConfig['config'], }); bridge.connect('conn-a'); diff --git a/src/memory/hybrid-search.test.ts b/src/memory/hybrid-search.test.ts index 4434747..62b6475 100644 --- a/src/memory/hybrid-search.test.ts +++ b/src/memory/hybrid-search.test.ts @@ -1,6 +1,5 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { HybridSearch } from './hybrid-search.js'; -import type { HybridSearchResult } from './hybrid-search.js'; import type { MemoryStore, SearchResult } from './store.js'; import type { VectorStore, VectorSearchResult } from './vector-store.js'; import type { EmbeddingProvider } from './embeddings.js'; @@ -153,7 +152,10 @@ describe('HybridSearch', () => { const keywordResult = results.find((r) => r.source === 'keyword'); expect(vectorResult).toBeDefined(); expect(keywordResult).toBeDefined(); - expect(vectorResult!.score).toBeGreaterThan(keywordResult!.score); + if (!vectorResult || !keywordResult) { + throw new Error('Expected both vector and keyword results'); + } + expect(vectorResult.score).toBeGreaterThan(keywordResult.score); }); it('falls back to keyword search when vector search fails', async () => { diff --git a/src/models/anthropic.ts b/src/models/anthropic.ts index a35a324..524071e 100644 --- a/src/models/anthropic.ts +++ b/src/models/anthropic.ts @@ -1,6 +1,6 @@ import Anthropic from '@anthropic-ai/sdk'; import type { Message as AnthropicMessage } from '@anthropic-ai/sdk/resources/messages/messages.js'; -import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, Message, MessageContentPart } from './types.js'; +import type { ChatRequest, ChatResponse, ChatStreamEvent, ModelClient, MessageContentPart } from './types.js'; export interface AnthropicClientConfig { apiKey?: string; // Falls back to ANTHROPIC_API_KEY env var @@ -23,21 +23,27 @@ function toAnthropicContent(content: string | MessageContentPart[]): string | un } if (part.type === 'image') { if (part.source.type === 'base64') { + if (!part.source.data) { + return { type: 'text', text: '[Image omitted: missing base64 data]' }; + } return { type: 'image', source: { type: 'base64', media_type: part.source.media_type, - data: part.source.data!, + data: part.source.data, }, }; } + if (!part.source.url) { + return { type: 'text', text: '[Image omitted: missing URL]' }; + } // URL-based image return { type: 'image', source: { type: 'url', - url: part.source.url!, + url: part.source.url, }, }; } @@ -52,6 +58,10 @@ function toAnthropicContent(content: string | MessageContentPart[]): string | un }); } +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' ? value as Record : undefined; +} + export class AnthropicClient implements ModelClient { private client: Anthropic; private model: string; @@ -67,14 +77,17 @@ export class AnthropicClient implements ModelClient { } async chat(request: ChatRequest): Promise { - const params: Record = { + type CreateParams = Parameters[0] & { + thinking?: { type: 'enabled'; budget_tokens: number }; + }; + const params: CreateParams = { model: this.model, max_tokens: request.maxTokens ?? this.defaultMaxTokens, system: request.system, messages: request.messages.map((m) => ({ role: m.role, content: toAnthropicContent(m.content), - })), + })) as CreateParams['messages'], }; if (request.tools && request.tools.length > 0) { @@ -83,18 +96,19 @@ export class AnthropicClient implements ModelClient { // Extended thinking mode — enable thinking with a budget if (request.thinking) { - params.max_tokens = Math.max(params.max_tokens as number, 16384); - (params as any).thinking = { type: 'enabled', budget_tokens: 4096 }; + params.max_tokens = Math.max(params.max_tokens, 16384); + params.thinking = { type: 'enabled', budget_tokens: 4096 }; } - const response = await this.client.messages.create(params as unknown as Parameters[0]) as AnthropicMessage; + const response = await this.client.messages.create(params) as AnthropicMessage; const textContent = response.content.find((c) => c.type === 'text'); const content = textContent?.type === 'text' ? textContent.text : ''; // Extract thinking content if present const thinkingBlock = response.content.find((c) => c.type === 'thinking'); - const thinkingContent = thinkingBlock && 'thinking' in thinkingBlock ? (thinkingBlock as any).text : undefined; + const thinkingText = asRecord(thinkingBlock)?.text; + const thinkingContent = typeof thinkingText === 'string' ? thinkingText : undefined; const toolCalls = response.content .filter((c): c is { type: 'tool_use'; id: string; name: string; input: unknown } => c.type === 'tool_use') diff --git a/src/models/local/ollama.ts b/src/models/local/ollama.ts index ceb18aa..7b32d64 100644 --- a/src/models/local/ollama.ts +++ b/src/models/local/ollama.ts @@ -137,8 +137,8 @@ export class OllamaClient implements ModelClient { private async checkToolSupport(): Promise { if (this._supportsTools !== null) {return this._supportsTools;} try { - const info = await this.client.show({ model: this.model }); - const caps: string[] = (info as any).capabilities ?? []; + const info = await this.client.show({ model: this.model }) as { capabilities?: string[] }; + const caps = info.capabilities ?? []; this._supportsTools = caps.includes('tools'); } catch { // Old Ollama or network issue — assume tools are supported @@ -151,6 +151,12 @@ export class OllamaClient implements ModelClient { * Convert Flynn ToolDefinition[] to Ollama Tool[] format. */ private convertTools(tools: ToolDefinition[]): Tool[] { + type OllamaParameter = { + type?: string | string[]; + items?: unknown; + description?: string; + enum?: unknown[]; + }; return tools.map(t => ({ type: 'function', function: { @@ -159,7 +165,7 @@ export class OllamaClient implements ModelClient { parameters: { type: t.input_schema.type, required: t.input_schema.required, - properties: t.input_schema.properties as Record, + properties: t.input_schema.properties as Record, }, }, })); @@ -186,7 +192,7 @@ export class OllamaClient implements ModelClient { // Extract content, checking for thinking field from reasoning models let content = response.message.content; let thinkingContent: string | undefined; - const thinking = (response.message as any).thinking; + const thinking = (response.message as unknown as { thinking?: unknown }).thinking; if (thinking && typeof thinking === 'string') { if (!content) { // If no regular content, use thinking as content @@ -245,7 +251,7 @@ export class OllamaClient implements ModelClient { } // Handle thinking field from reasoning models (e.g., deepseek-r1) - const thinking = (chunk.message as any)?.thinking; + const thinking = (chunk.message as unknown as { thinking?: unknown } | undefined)?.thinking; if (thinking && typeof thinking === 'string') { yield { type: 'content', content: thinking }; } @@ -259,7 +265,7 @@ export class OllamaClient implements ModelClient { if (chunk.done) { // Handle tool_calls in the final chunk - const toolCalls = (chunk.message as any)?.tool_calls; + const toolCalls = (chunk.message as unknown as { tool_calls?: Array<{ function: { name: string; arguments: Record } }> } | undefined)?.tool_calls; if (toolCalls && Array.isArray(toolCalls)) { for (let i = 0; i < toolCalls.length; i++) { const tc = toolCalls[i]; diff --git a/src/models/openai.oauth.test.ts b/src/models/openai.oauth.test.ts index 30d6235..cba050d 100644 --- a/src/models/openai.oauth.test.ts +++ b/src/models/openai.oauth.test.ts @@ -12,7 +12,7 @@ vi.mock('../auth/openai.js', () => ({ })), })); -function makeSse(events: Array<{ event: string; data: any }>): string { +function makeSse(events: Array<{ event: string; data: unknown }>): string { return events .map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`) .join(''); @@ -39,8 +39,12 @@ describe('OpenAIClient OAuth (Codex)', () => { { event: 'response.completed', data: { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } } }, ]); - globalThis.fetch = vi.fn(async (_url: any, init?: any) => { - const parsed = JSON.parse(init.body); + globalThis.fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = typeof init?.body === 'string' ? init.body : ''; + if (!body) { + throw new Error('Expected JSON body'); + } + const parsed = JSON.parse(body) as Record; expect(parsed.store).toBe(false); expect(parsed.stream).toBe(true); expect(typeof parsed.instructions).toBe('string'); @@ -54,7 +58,7 @@ describe('OpenAIClient OAuth (Codex)', () => { }); return new Response(stream, { status: 200 }); - }) as any; + }) as typeof fetch; const client = new OpenAIClient({ model: 'gpt-5.3-codex', useOAuth: true }); const resp = await client.chat({ diff --git a/src/models/openai.ts b/src/models/openai.ts index 3075648..6fc0dad 100644 --- a/src/models/openai.ts +++ b/src/models/openai.ts @@ -27,13 +27,22 @@ function toOpenAIContent(content: string | MessageContentPart[]): string | OpenA return { type: 'text', text: part.text }; } if (part.type === 'image') { + if (part.source.type === 'base64' && !part.source.data) { + return { type: 'text', text: '[Image omitted: missing base64 data]' }; + } + if (part.source.type !== 'base64' && !part.source.url) { + return { type: 'text', text: '[Image omitted: missing URL]' }; + } // OpenAI accepts data URIs or regular URLs const url = part.source.type === 'base64' - ? `data:${part.source.media_type};base64,${part.source.data!}` - : part.source.url!; + ? `data:${part.source.media_type};base64,${part.source.data}` + : part.source.url; return { type: 'image_url', image_url: { url } }; } if (part.type === 'audio') { + if (!part.source.data) { + return { type: 'text', text: '[Audio omitted: missing data]' }; + } // OpenAI native audio input via input_audio content part // Determine format from MIME type (OpenAI supports: wav, mp3, flac, opus, ogg, webm) const formatMap: Record = { @@ -157,9 +166,13 @@ export class OpenAIClient implements ModelClient { } } if (!data) {return;} - let obj: any; + let obj: Record; try { - obj = JSON.parse(data); + const parsed = JSON.parse(data) as unknown; + if (!parsed || typeof parsed !== 'object') { + return; + } + obj = parsed as Record; } catch { return; } @@ -169,8 +182,9 @@ export class OpenAIClient implements ModelClient { } if (obj.type === 'response.completed') { - const u = obj.response?.usage; - if (u) { + const response = obj.response as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined; + const u = response?.usage; + if (u && typeof u === 'object') { usage = { inputTokens: u.input_tokens ?? 0, outputTokens: u.output_tokens ?? 0, @@ -179,7 +193,8 @@ export class OpenAIClient implements ModelClient { } if (obj.type === 'response.failed') { - const detail = obj.response?.error?.message ?? 'OpenAI OAuth response failed'; + const response = obj.response as { error?: { message?: string } } | undefined; + const detail = response?.error?.message ?? 'OpenAI OAuth response failed'; throw new Error(detail); } }; @@ -247,7 +262,7 @@ export class OpenAIClient implements ModelClient { // Extended thinking/reasoning mode for o1/o3 models if (request.thinking) { - (params as any).reasoning_effort = 'medium'; + (params as OpenAI.ChatCompletionCreateParamsNonStreaming & { reasoning_effort?: 'low' | 'medium' | 'high' }).reasoning_effort = 'medium'; } let response: OpenAI.ChatCompletion; diff --git a/src/models/router.test.ts b/src/models/router.test.ts index 0d2e59c..cbb281e 100644 --- a/src/models/router.test.ts +++ b/src/models/router.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { ModelRouter } from './router.js'; import type { ModelClient, ChatResponse, ChatStreamEvent } from './types.js'; @@ -314,10 +314,13 @@ describe('setClient and labels', () => { const newFastClient = router.getClient('fast'); expect(newFastClient).toBeDefined(); + if (!newFastClient) { + throw new Error('Expected fast client to be set'); + } await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast'); - expect(newFastClient!.chat).toHaveBeenCalled(); - expect(newFastClient!.chat).toHaveBeenCalledTimes(1); + expect(newFastClient.chat).toHaveBeenCalled(); + expect(newFastClient.chat).toHaveBeenCalledTimes(1); expect(mockClient1.chat).toHaveBeenCalledTimes(1); }); @@ -336,10 +339,13 @@ describe('setClient and labels', () => { const newClient = router.getClient('complex'); expect(newClient).toBe(mockClient2); + if (!newClient) { + throw new Error('Expected complex client to be set'); + } await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'complex'); - expect(newClient!.chat).toHaveBeenCalled(); + expect(newClient.chat).toHaveBeenCalled(); }); it('getLabel returns the label set by setClient', () => { @@ -424,19 +430,25 @@ describe('setClient and labels', () => { const initialFastClient = router.getClient('fast'); expect(initialFastClient).toBeDefined(); + if (!initialFastClient) { + throw new Error('Expected initial fast client to exist'); + } await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast'); - expect(initialFastClient!.chat).toHaveBeenCalled(); - expect(initialFastClient!.chat).toHaveBeenCalledTimes(1); + expect(initialFastClient.chat).toHaveBeenCalled(); + expect(initialFastClient.chat).toHaveBeenCalledTimes(1); router.setClient('fast', mockClient2, 'fast-replaced'); const newFastClient = router.getClient('fast'); + if (!newFastClient) { + throw new Error('Expected replaced fast client to exist'); + } await router.chat({ messages: [{ role: 'user', content: 'Test' }] }, 'fast'); - expect(newFastClient!.chat).toHaveBeenCalled(); - expect(newFastClient!.chat).toHaveBeenCalledTimes(1); - expect(initialFastClient!.chat).toHaveBeenCalledTimes(1); + expect(newFastClient.chat).toHaveBeenCalled(); + expect(newFastClient.chat).toHaveBeenCalledTimes(1); + expect(initialFastClient.chat).toHaveBeenCalledTimes(1); }); it('strict tier mode disables fallback chain for that tier', async () => { diff --git a/src/skills/installer.test.ts b/src/skills/installer.test.ts index 584a37f..1b6570a 100644 --- a/src/skills/installer.test.ts +++ b/src/skills/installer.test.ts @@ -62,8 +62,11 @@ describe('SkillInstaller', () => { const skill = installer.install(sourceDir); expect(skill).not.toBeNull(); - expect(skill!.manifest.name).toBe('my-skill'); - expect(skill!.instructions).toBe('# My Skill\nDo the thing.'); + if (!skill) { + throw new Error('Expected installed skill'); + } + expect(skill.manifest.name).toBe('my-skill'); + expect(skill.instructions).toBe('# My Skill\nDo the thing.'); expect(existsSync(join(managedDir, 'my-skill', 'SKILL.md'))).toBe(true); }); @@ -79,7 +82,10 @@ describe('SkillInstaller', () => { const skill = installer.install(sourceDir); expect(skill).not.toBeNull(); - expect(skill!.manifest.tier).toBe('managed'); + if (!skill) { + throw new Error('Expected installed skill'); + } + expect(skill.manifest.tier).toBe('managed'); }); it('uses manifest.json name field for the installed directory name', () => { @@ -131,8 +137,11 @@ describe('SkillInstaller', () => { const skill = installer.install(sourceV2); expect(skill).not.toBeNull(); - expect(skill!.manifest.version).toBe('2.0.0'); - expect(skill!.instructions).toBe('# V2'); + if (!skill) { + throw new Error('Expected installed skill'); + } + expect(skill.manifest.version).toBe('2.0.0'); + expect(skill.instructions).toBe('# V2'); }); it('throws when source directory does not exist', () => {