From 486ada291c5f4cebbe22821f44060472f525bd50 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema <54610255+mcheemaa@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:25:57 +0000 Subject: [PATCH] refactor: extract duplicated code into shared utilities - Create src/shared/strings.ts with escapeHtml, truncate, isRecord, errorMessage - Create src/chat/page-tools.ts consolidating page-tool normalization helpers - Replace 4 duplicate escapeHtml implementations with shared import - Replace 6 duplicate truncate implementations with shared import - Replace 4 duplicate isRecord/isObject type guards with shared import - Consolidate ~13 duplicated page-tool helpers (normalizePageToolName, normalizePageUrl, normalizePagePath, stringField, numberField, etc.) from run-timeline.ts and continuity-context.ts - Replace err instanceof Error ? err.message : String(err) with errorMessage() in 13 high-frequency files (~75 call sites) All 2640 tests pass. Lint and typecheck clean. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/agent/judge-query.ts | 6 +- src/agent/murph-context.ts | 6 +- src/channels/email.ts | 13 +-- src/channels/slack-actions.ts | 13 +-- src/channels/slack-egress.ts | 17 ++-- src/channels/slack.ts | 9 +- src/channels/slack/render-first-hour-dm.ts | 7 +- src/channels/telegram.ts | 11 +-- src/chat/continuity-context.ts | 90 +++----------------- src/chat/page-tools.ts | 77 +++++++++++++++++ src/chat/run-timeline.ts | 99 ++++------------------ src/chat/sdk-to-wire.ts | 5 +- src/chat/transcript-search.ts | 5 +- src/chat/util/escape.ts | 16 +--- src/core/health-page.ts | 10 +-- src/evolution/gate.ts | 6 +- src/evolution/invariant-check.ts | 3 +- src/evolution/reflection-subprocess.ts | 9 +- src/index.ts | 25 +++--- src/mcp/audit.ts | 6 +- src/memory-files/storage.ts | 17 ++-- src/scheduler/service.ts | 11 +-- src/secrets/form-page.ts | 9 +- src/shared/strings.ts | 31 +++++++ src/skills/storage.ts | 17 ++-- src/subagents/storage.ts | 17 ++-- src/ui/api/phantom-config-storage.ts | 9 +- src/ui/api/plugins.ts | 11 +-- src/ui/html.ts | 14 +-- 29 files changed, 252 insertions(+), 317 deletions(-) create mode 100644 src/chat/page-tools.ts create mode 100644 src/shared/strings.ts diff --git a/src/agent/judge-query.ts b/src/agent/judge-query.ts index 5eec41d2..96a59e38 100644 --- a/src/agent/judge-query.ts +++ b/src/agent/judge-query.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { buildAgentRuntimeEnv, resolveAgentRuntimeModel } from "../config/providers.ts"; import type { PhantomConfig } from "../config/types.ts"; +import { truncate } from "../shared/strings.ts"; import { query } from "./agent-sdk.ts"; import { extractTextFromMessage } from "./message-utils.ts"; import { getThinkingConfig } from "./thinking-config.ts"; @@ -364,8 +365,3 @@ function formatZodError(error: z.ZodError): string { const suffix = error.issues.length > 3 ? ` (+${error.issues.length - 3} more)` : ""; return `${issues.join("; ")}${suffix}`; } - -function truncate(text: string, max: number): string { - if (text.length <= max) return text; - return `${text.slice(0, max)}...`; -} diff --git a/src/agent/murph-context.ts b/src/agent/murph-context.ts index ec6da4d6..1809a3b0 100644 --- a/src/agent/murph-context.ts +++ b/src/agent/murph-context.ts @@ -1,3 +1,5 @@ +import { isRecord } from "../shared/strings.ts"; + export type MurphContextTransform = (messages: unknown[], signal?: AbortSignal) => Promise | unknown[]; export type MurphContextSource = string | undefined | (() => string | undefined | Promise); @@ -74,10 +76,6 @@ function hasRole(message: unknown, role: string): boolean { return isRecord(message) && message.role === role; } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} - function textField(record: Record): string { return typeof record.text === "string" ? record.text : ""; } diff --git a/src/channels/email.ts b/src/channels/email.ts index d5b4ebbf..ad0071d2 100644 --- a/src/channels/email.ts +++ b/src/channels/email.ts @@ -5,6 +5,7 @@ */ import { randomUUID } from "node:crypto"; +import { errorMessage } from "../shared/strings.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; export type EmailChannelConfig = { @@ -91,7 +92,7 @@ export class EmailChannel implements Channel { this.idleLoopPromise = this.startIdleLoop(); } catch (err: unknown) { this.connectionState = "error"; - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[email] Failed to connect: ${msg}`); throw err; } @@ -113,7 +114,7 @@ export class EmailChannel implements Channel { try { await this.imapClient?.logout(); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[email] Error during IMAP disconnect: ${msg}`); } @@ -182,7 +183,7 @@ export class EmailChannel implements Channel { try { await this.imapClient.idle({ abort: this.idleAbort.signal }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); if (msg.includes("abort")) break; console.warn(`[email] IDLE error: ${msg}`); break; @@ -195,7 +196,7 @@ export class EmailChannel implements Channel { lock.release(); } } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[email] IDLE loop error: ${msg}`); } } @@ -265,12 +266,12 @@ export class EmailChannel implements Channel { try { await this.messageHandler(inbound); } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.error(`[email] Error handling email from ${fromAddress}: ${errMsg}`); } } } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[email] Error processing unread: ${msg}`); } } diff --git a/src/channels/slack-actions.ts b/src/channels/slack-actions.ts index edee0d41..185720c6 100644 --- a/src/channels/slack-actions.ts +++ b/src/channels/slack-actions.ts @@ -7,6 +7,7 @@ */ import type { App } from "@slack/bolt"; +import { errorMessage } from "../shared/strings.ts"; import { FEEDBACK_ACTION_IDS, buildFeedbackAckBlocks, emitFeedback, parseFeedbackAction } from "./feedback.ts"; import { MORNING_BRIEF_LOCK_ACTION_ID, @@ -138,7 +139,7 @@ export function registerSlackActions(app: App): void { blocks: [...cleaned, ...ackBlocks], } as unknown as Parameters[0]); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] Failed to update feedback buttons: ${msg}`); } }); @@ -191,7 +192,7 @@ export function registerSlackActions(app: App): void { ], } as unknown as Parameters[0]); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] Failed to update action buttons: ${msg}`); } @@ -259,7 +260,7 @@ export function registerSlackActions(app: App): void { ], } as unknown as Parameters[0]); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] Failed to update morning-brief buttons: ${msg}`); } @@ -267,7 +268,7 @@ export function registerSlackActions(app: App): void { try { await morningBriefRecorder({ userId, channel: channelId, choice: meta.choice, clickedAt: Date.now() }); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] morning-brief recorder failed: ${msg}`); } } else { @@ -315,7 +316,7 @@ export function registerSlackActions(app: App): void { clickedAt: Date.now(), }); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] first-hour draft recorder failed: ${msg}`); } } else { @@ -346,7 +347,7 @@ export function registerSlackActions(app: App): void { blocks: updatedBlocks, } as unknown as Parameters[0]); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] Failed to update first-hour draft buttons: ${msg}`); } }); diff --git a/src/channels/slack-egress.ts b/src/channels/slack-egress.ts index ef47636b..371837c5 100644 --- a/src/channels/slack-egress.ts +++ b/src/channels/slack-egress.ts @@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto"; import type { App } from "@slack/bolt"; +import { errorMessage } from "../shared/strings.ts"; import type { SlackBlock } from "./feedback.ts"; import { buildFeedbackBlocks } from "./feedback.ts"; import { splitMessage, toSlackMarkdown, truncateForSlack } from "./slack-formatter.ts"; @@ -57,7 +58,7 @@ export async function egressPostToChannel(ctx: EgressContext, channelId: string, const result = await ctx.client.chat.postMessage({ channel: channelId, text: chunk }); lastTs = result.ts ?? null; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[${ctx.logTag}] Failed to post to channel ${channelId}: ${msg}`); return null; } @@ -97,12 +98,12 @@ export async function egressSendDm( ); return result.ts ?? null; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[${ctx.logTag}] Failed to post DM blocks to user ${userId}: ${msg}`); return null; } } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[${ctx.logTag}] Failed to send DM to user ${userId}: ${msg}`); return null; } @@ -121,7 +122,7 @@ export async function egressPostThinking( }); return result.ts ?? null; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[${ctx.logTag}] Failed to post thinking indicator: ${msg}`); return null; } @@ -145,7 +146,7 @@ export async function egressUpdateMessage( if (blocks) updateArgs.blocks = blocks; await ctx.client.chat.update(updateArgs as unknown as Parameters[0]); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[${ctx.logTag}] Failed to update message: ${msg}`); } } @@ -165,7 +166,7 @@ export async function egressUpdateWithFeedback( const updateArgs: Record = { channel, ts, text: truncated, blocks }; await ctx.client.chat.update(updateArgs as unknown as Parameters[0]); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[${ctx.logTag}] Failed to update message with feedback: ${msg}`); } } @@ -179,7 +180,7 @@ export async function egressAddReaction( try { await ctx.client.reactions.add({ channel, timestamp: messageTs, name: emoji }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); if (!msg.includes("already_reacted")) { console.warn(`[${ctx.logTag}] Failed to add reaction :${emoji}:: ${msg}`); } @@ -195,7 +196,7 @@ export async function egressRemoveReaction( try { await ctx.client.reactions.remove({ channel, timestamp: messageTs, name: emoji }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); if (!msg.includes("no_reaction")) { console.warn(`[${ctx.logTag}] Failed to remove reaction :${emoji}:: ${msg}`); } diff --git a/src/channels/slack.ts b/src/channels/slack.ts index 51cd47ed..36c70cf7 100644 --- a/src/channels/slack.ts +++ b/src/channels/slack.ts @@ -1,4 +1,5 @@ import { App, type LogLevel, SocketModeReceiver } from "@slack/bolt"; +import { errorMessage } from "../shared/strings.ts"; import type { SlackBlock } from "./feedback.ts"; import { registerSlackActions } from "./slack-actions.ts"; import { @@ -242,7 +243,7 @@ export class SlackChannel implements Channel { // down tenant even though no `disconnected` event ever fired // (the SocketModeClient never made it past handshake). this.metrics.recordState("error"); - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[slack] Failed to connect: ${msg}`); throw err; } @@ -254,7 +255,7 @@ export class SlackChannel implements Channel { try { await this.app.stop(); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[slack] Error during disconnect: ${msg}`); } @@ -351,7 +352,7 @@ export class SlackChannel implements Channel { try { await this.messageHandler(inbound); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[slack] Error handling app_mention: ${msg}`); } }); @@ -405,7 +406,7 @@ export class SlackChannel implements Channel { try { await this.messageHandler(inbound); } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.error(`[slack] Error handling DM: ${errMsg}`); } }); diff --git a/src/channels/slack/render-first-hour-dm.ts b/src/channels/slack/render-first-hour-dm.ts index 8c5e3d4b..c6320aa8 100644 --- a/src/channels/slack/render-first-hour-dm.ts +++ b/src/channels/slack/render-first-hour-dm.ts @@ -1,3 +1,5 @@ +import { truncate } from "../../shared/strings.ts"; + // Block Kit renderer for the do_first_hour_of_work DM (architect ยง5). // // Shape: 1 header + 1 markdown summary + N draft sections (each with 3 @@ -178,8 +180,3 @@ export function renderFirstHourDmFallbackText(persona: PersonaWorkPlan, draftCou const tail = draftCount === 1 ? "1 item" : `${draftCount} items`; return `${persona.intro_line} I drafted ${tail} for you. ${persona.footer_line}`; } - -function truncate(s: string, max: number): string { - if (s.length <= max) return s; - return `${s.slice(0, Math.max(0, max - 3))}...`; -} diff --git a/src/channels/telegram.ts b/src/channels/telegram.ts index 7750a2d5..9f4d9dca 100644 --- a/src/channels/telegram.ts +++ b/src/channels/telegram.ts @@ -4,6 +4,7 @@ * MarkdownV2 formatting, and command handling. */ +import { errorMessage } from "../shared/strings.ts"; import type { Channel, ChannelCapabilities, InboundMessage, OutboundMessage, SentMessage } from "./types.ts"; type TelegrafBot = { @@ -91,7 +92,7 @@ export class TelegramChannel implements Channel { console.log("[telegram] Bot connected via long polling"); } catch (err: unknown) { this.connectionState = "error"; - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[telegram] Failed to connect: ${msg}`); throw err; } @@ -109,7 +110,7 @@ export class TelegramChannel implements Channel { try { this.bot?.stop(); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[telegram] Error during disconnect: ${msg}`); } @@ -190,7 +191,7 @@ export class TelegramChannel implements Channel { parse_mode: "MarkdownV2", }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); // "message is not modified" is expected when text hasn't changed if (!msg.includes("message is not modified")) { console.warn(`[telegram] Failed to edit message: ${msg}`); @@ -243,7 +244,7 @@ export class TelegramChannel implements Channel { try { await this.messageHandler(inbound); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[telegram] Error handling message: ${msg}`); } }); @@ -281,7 +282,7 @@ export class TelegramChannel implements Channel { try { await this.messageHandler(inbound); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[telegram] Error handling callback: ${msg}`); } }); diff --git a/src/chat/continuity-context.ts b/src/chat/continuity-context.ts index 854a0175..54bfa9cf 100644 --- a/src/chat/continuity-context.ts +++ b/src/chat/continuity-context.ts @@ -1,11 +1,21 @@ +import { isRecord } from "../shared/strings.ts"; +import { truncate } from "../shared/strings.ts"; import type { ChatEventLog, ChatStreamEvent } from "./event-log.ts"; +import { + normalizePagePath, + normalizePageToolName, + normalizePageUrl, + numberField, + parseJsonRecord, + stringField, + urlFromText, +} from "./page-tools.ts"; import type { ChatRunTimelineStore, DurableRunTimelineArtifactSummary } from "./run-timeline.ts"; const DEFAULT_EVENT_SCAN_LIMIT = 5000; const MAX_ARTIFACTS = 8; const MAX_COMPACTIONS = 3; const MAX_LABEL_LENGTH = 90; -const PAGE_TOOLS = new Set(["phantom_create_page", "phantom_preview_page"]); type BuildChatContinuityContextInput = { sessionId: string; @@ -134,7 +144,7 @@ function artifactFromTool(tool: ToolAccumulator): PageArtifact | undefined { const toolName = normalizePageToolName(tool.toolName); if (!toolName) return undefined; - const input = recordFromUnknown(tool.input); + const input = isRecord(tool.input) ? tool.input : undefined; const output = parseJsonRecord(tool.output); const path = normalizePagePath(stringField(output, "path") ?? stringField(input, "path")); const url = normalizePageUrl( @@ -182,16 +192,6 @@ function timelineArtifactFromSummary(artifact: DurableRunTimelineArtifactSummary }; } -function normalizePageToolName(toolName: string | undefined): string | undefined { - if (!toolName) return undefined; - for (const pageToolName of PAGE_TOOLS) { - if (toolName === pageToolName || toolName.endsWith(`__${pageToolName}`) || toolName.endsWith(`:${pageToolName}`)) { - return pageToolName; - } - } - return undefined; -} - function dedupeArtifacts(artifacts: PageArtifact[]): PageArtifact[] { const byKey = new Map(); for (const artifact of artifacts) { @@ -202,69 +202,5 @@ function dedupeArtifacts(artifacts: PageArtifact[]): PageArtifact[] { } function parsePayload(event: ChatStreamEvent): Record | undefined { - try { - const parsed = JSON.parse(event.payload_json); - return recordFromUnknown(parsed); - } catch { - return undefined; - } -} - -function parseJsonRecord(value: string | undefined): Record | undefined { - if (!value) return undefined; - try { - return recordFromUnknown(JSON.parse(value)); - } catch { - return undefined; - } -} - -function recordFromUnknown(value: unknown): Record | undefined { - if (value === null || typeof value !== "object" || Array.isArray(value)) { - return undefined; - } - return value as Record; -} - -function stringField(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function numberField(record: Record | undefined, key: string): number | undefined { - const value = record?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function normalizePageUrl(url: string | undefined): string | undefined { - const trimmed = stripTrailingPunctuation(url?.trim() ?? ""); - if (!trimmed || !trimmed.includes("/ui/") || trimmed.includes("/ui/login") || trimmed.includes("magic=")) { - return undefined; - } - return trimmed; -} - -function normalizePagePath(path: string | undefined): string | undefined { - const cleaned = path?.trim().replace(/^\/+/, "").replace(/^ui\//, ""); - if (!cleaned || cleaned.includes("..") || cleaned.includes("\0") || cleaned.startsWith("login")) { - return undefined; - } - return cleaned; -} - -function urlFromText(text: string | undefined): string | undefined { - if (!text) return undefined; - const match = text.match(/https?:\/\/[^\s"']+\/ui\/[^\s"']+|\/ui\/[^\s"']+/); - return normalizePageUrl(match?.[0]); -} - -function stripTrailingPunctuation(value: string): string { - return value.replace(/[),.;]+$/g, ""); -} - -function truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 3)}...`; + return parseJsonRecord(event.payload_json); } diff --git a/src/chat/page-tools.ts b/src/chat/page-tools.ts new file mode 100644 index 00000000..4dd2e6bf --- /dev/null +++ b/src/chat/page-tools.ts @@ -0,0 +1,77 @@ +/** + * Shared helpers for identifying and normalising page-tool artifacts + * (phantom_create_page / phantom_preview_page). Used by both + * run-timeline.ts and continuity-context.ts. + */ + +import { isRecord } from "../shared/strings.ts"; + +export const PAGE_TOOL_NAMES = ["phantom_create_page", "phantom_preview_page"] as const; +export type PageToolName = (typeof PAGE_TOOL_NAMES)[number]; + +export function normalizePageToolName(toolName: string | undefined): PageToolName | undefined { + if (!toolName) return undefined; + for (const pageToolName of PAGE_TOOL_NAMES) { + if (toolName === pageToolName || toolName.endsWith(`__${pageToolName}`) || toolName.endsWith(`:${pageToolName}`)) { + return pageToolName; + } + } + return undefined; +} + +export function normalizePageUrl(value: string | undefined): string | undefined { + const trimmed = stripTrailingPunctuation(value?.trim() ?? ""); + if (!trimmed || !trimmed.includes("/ui/") || trimmed.includes("/ui/login") || trimmed.includes("magic=")) { + return undefined; + } + if (hasSensitiveQuery(trimmed)) return undefined; + return trimmed; +} + +export function normalizePagePath(value: string | undefined): string | undefined { + const cleaned = value?.trim().replace(/^\/+/, "").replace(/^ui\//, ""); + if (!cleaned || cleaned.includes("..") || cleaned.includes("\0") || cleaned.startsWith("login")) { + return undefined; + } + return cleaned; +} + +export function urlFromPath(path: string | undefined): string | undefined { + return path ? `/ui/${path}` : undefined; +} + +export function urlFromText(value: string | undefined): string | undefined { + if (!value) return undefined; + const match = value.match(/(?:https?:\/\/[^\s"']*\/ui\/[^\s"']+|\/ui\/[^\s"']+)/); + return normalizePageUrl(match?.[0]); +} + +export function stripTrailingPunctuation(value: string): string { + return value.replace(/[),.;]+$/g, ""); +} + +export function hasSensitiveQuery(value: string): boolean { + return /[?&](?:api[_-]?key|token|secret|password|access_token|code|magic)=/i.test(value); +} + +export function parseJsonRecord(value: string | undefined): Record | undefined { + if (!value) return undefined; + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +export function stringField(record: Record | undefined, key: string): string | undefined { + const value = record?.[key]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export function numberField(record: Record | undefined, key: string): number | undefined { + const value = record?.[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} diff --git a/src/chat/run-timeline.ts b/src/chat/run-timeline.ts index 657e59e3..5406c8f6 100644 --- a/src/chat/run-timeline.ts +++ b/src/chat/run-timeline.ts @@ -1,4 +1,16 @@ import type { Database } from "bun:sqlite"; +import { isRecord } from "../shared/strings.ts"; +import { truncate } from "../shared/strings.ts"; +import { + normalizePagePath, + normalizePageToolName, + normalizePageUrl, + numberField, + parseJsonRecord, + stringField, + urlFromPath, + urlFromText, +} from "./page-tools.ts"; import { redactSensitiveText } from "./redaction.ts"; import type { SessionErrorSubtype, StopReason } from "./types.ts"; import type { ChatWireFrame } from "./types.ts"; @@ -140,11 +152,8 @@ const MAX_SUMMARY_TEXT = 240; const MAX_OUTPUT_SUMMARY_TEXT = 360; const MAX_COLLECTION_ITEMS = 25; const MAX_INPUT_PARTS = 3; -const PAGE_TOOL_NAMES = ["phantom_create_page", "phantom_preview_page"] as const; const MAX_ARTIFACT_TITLE = 90; -type PageToolName = (typeof PAGE_TOOL_NAMES)[number]; - type PageArtifactInput = { path?: string; title?: string; @@ -539,7 +548,7 @@ export class DurableRunTimelineBuilder { private captureArtifactInput(tool: DurableRunTimelineToolSummary, toolCallId: string, input: unknown): void { if (!normalizePageToolName(tool.name)) return; - const record = recordFromUnknown(input); + const record = isRecord(input) ? input : undefined; if (!record) return; const path = normalizePagePath(stringField(record, "path")); const title = stringField(record, "title"); @@ -638,7 +647,7 @@ function parseRunTimelineSummary(summaryJson: string, row: ChatRunTimelineRow): } function isRunTimelineSummary(value: unknown): value is DurableRunTimelineSummary { - if (!isObject(value)) return false; + if (!isRecord(value)) return false; return ( value.schemaVersion === 1 && typeof value.status === "string" && @@ -670,7 +679,7 @@ function summarizeToolInput(input: unknown): string | undefined { } return "Input captured."; } - if (!isObject(input)) return undefined; + if (!isRecord(input)) return undefined; const parts: string[] = []; for (const key of ["command", "cmd"]) { @@ -710,80 +719,6 @@ function summarizeToolOutput(status: "success" | "error", output: string | undef return "Tool produced output."; } -function normalizePageToolName(toolName: string | undefined): PageToolName | undefined { - if (!toolName) return undefined; - for (const pageToolName of PAGE_TOOL_NAMES) { - if (toolName === pageToolName || toolName.endsWith(`__${pageToolName}`) || toolName.endsWith(`:${pageToolName}`)) { - return pageToolName; - } - } - return undefined; -} - -function parseJsonRecord(value: string | undefined): Record | undefined { - if (!value) return undefined; - try { - return recordFromUnknown(JSON.parse(value)); - } catch { - return undefined; - } -} - -function recordFromUnknown(value: unknown): Record | undefined { - if (!isObject(value)) return undefined; - return value; -} - -function stringField(record: Record | undefined, key: string): string | undefined { - const value = record?.[key]; - if (typeof value !== "string") return undefined; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function numberField(record: Record | undefined, key: string): number | undefined { - const value = record?.[key]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function normalizePageUrl(value: string | undefined): string | undefined { - if (!value) return undefined; - const trimmed = stripTrailingPunctuation(value.trim()); - if (!trimmed.includes("/ui/")) return undefined; - if (trimmed.includes("/ui/login") || trimmed.includes("magic=") || hasSensitiveQuery(trimmed)) return undefined; - return trimmed; -} - -function normalizePagePath(value: string | undefined): string | undefined { - if (!value) return undefined; - const cleaned = value.trim().replace(/^\/+/, "").replace(/^ui\//, ""); - if (!cleaned || cleaned.includes("..") || cleaned.includes("\0") || cleaned.startsWith("login")) return undefined; - return cleaned; -} - -function urlFromPath(path: string | undefined): string | undefined { - return path ? `/ui/${path}` : undefined; -} - -function urlFromText(value: string | undefined): string | undefined { - if (!value) return undefined; - const match = value.match(/(?:https?:\/\/[^\s"']*\/ui\/[^\s"']+|\/ui\/[^\s"']+)/); - return normalizePageUrl(match?.[0]); -} - -function stripTrailingPunctuation(value: string): string { - return value.replace(/[),.;]+$/g, ""); -} - -function hasSensitiveQuery(value: string): boolean { - return /[?&](?:api[_-]?key|token|secret|password|access_token|code|magic)=/i.test(value); -} - -function truncate(value: string, maxLength: number): string { - if (value.length <= maxLength) return value; - return `${value.slice(0, maxLength - 3)}...`; -} - function summarizeCommand(command: string): string | undefined { const redacted = redact(command).trim(); if (redacted.length === 0) return undefined; @@ -824,10 +759,6 @@ function isTruncated(value: string | undefined, maxLength: number): boolean { return typeof value === "string" && (value.length > maxLength || redact(value).length > maxLength); } -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function isSensitiveKey(key: string): boolean { return /(?:api[_-]?key|access[_-]?key|private[_-]?key|secret|token|password|auth|authorization|cookie|credential|session|oauth|^code$)/i.test( key, diff --git a/src/chat/sdk-to-wire.ts b/src/chat/sdk-to-wire.ts index 85b0512f..15491f54 100644 --- a/src/chat/sdk-to-wire.ts +++ b/src/chat/sdk-to-wire.ts @@ -3,6 +3,7 @@ // handlers. The assistant and stream_event handlers live in // sdk-to-wire-handlers.ts to keep both files under 300 lines. +import { isRecord } from "../shared/strings.ts"; import { type TranslationContext, handleAssistant, handleStreamEvent } from "./sdk-to-wire-handlers.ts"; import type { ChatWireFrame, StopReason, ToolCallResultFrame, ToolCallRunningFrame } from "./types.ts"; @@ -138,10 +139,6 @@ function handleSystem(msg: Record, ctx: TranslationContext): Ch const TOOL_RESULT_OUTPUT_LIMIT = 12_000; const SAFE_TOOL_ERROR_MESSAGE = "Tool returned an error. Details are hidden for safety."; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function safeToolResultText(content: unknown): string | undefined { if (typeof content === "string") return content; if (Array.isArray(content)) { diff --git a/src/chat/transcript-search.ts b/src/chat/transcript-search.ts index be3de0f7..afeea67d 100644 --- a/src/chat/transcript-search.ts +++ b/src/chat/transcript-search.ts @@ -1,4 +1,5 @@ import type { Database } from "bun:sqlite"; +import { isRecord } from "../shared/strings.ts"; import { redactSensitiveText } from "./redaction.ts"; export type ChatTranscriptRole = "user" | "assistant" | "all"; @@ -240,10 +241,6 @@ function normalizeWhitespace(value: string): string { return value.replace(/\s+/g, " ").trim(); } -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - function stringField(record: Record, key: string): string | undefined { const value = record[key]; return typeof value === "string" && value.trim().length > 0 ? value : undefined; diff --git a/src/chat/util/escape.ts b/src/chat/util/escape.ts index 95261fe4..5090ce09 100644 --- a/src/chat/util/escape.ts +++ b/src/chat/util/escape.ts @@ -1,13 +1,3 @@ -// Minimal HTML entity escape for the five characters that matter in -// quoted attributes and element content. Used by chat server code that -// interpolates operator-supplied strings (agent name) into email HTML -// and PWA manifest JSON. Matches src/ui/html.ts but lives in chat/ so -// chat modules do not reach across subsystems for a five-line helper. -export function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} +// Re-export the canonical escapeHtml from shared/strings.ts so existing +// callers that import from "chat/util/escape.ts" continue to work unchanged. +export { escapeHtml } from "../../shared/strings.ts"; diff --git a/src/core/health-page.ts b/src/core/health-page.ts index 50cc400f..bd1e25e5 100644 --- a/src/core/health-page.ts +++ b/src/core/health-page.ts @@ -1,5 +1,6 @@ import type { MemoryHealth } from "../memory/types.ts"; import type { SchedulerHealthSummary } from "../scheduler/health.ts"; +import { escapeHtml } from "../shared/strings.ts"; export type HealthPayload = { status: string; @@ -17,15 +18,6 @@ export type HealthPayload = { scheduler?: SchedulerHealthSummary; }; -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - function humanUptime(seconds: number): string { if (typeof seconds !== "number" || seconds < 0) return "-"; const d = Math.floor(seconds / 86400); diff --git a/src/evolution/gate.ts b/src/evolution/gate.ts index d1037f24..87941d1c 100644 --- a/src/evolution/gate.ts +++ b/src/evolution/gate.ts @@ -2,6 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr import { dirname, join } from "node:path"; import { JudgeSubprocessError } from "../agent/judge-query.ts"; import type { AgentRuntime } from "../agent/runtime.ts"; +import { truncate } from "../shared/strings.ts"; import type { EvolutionConfig } from "./config.ts"; import { GateJudgeResult, type GateJudgeResultType, gateJudgePrompt } from "./gate-prompt.ts"; import type { GateDecision } from "./gate-types.ts"; @@ -181,11 +182,6 @@ export function recordGateDecision(config: EvolutionConfig, decision: GateDecisi } } -function truncate(text: string, max: number): string { - if (text.length <= max) return text; - return `${text.slice(0, max)}...`; -} - function inferChannelType(sessionKey: string): string { const prefix = sessionKey.split(":")[0]; return prefix || "cli"; diff --git a/src/evolution/invariant-check.ts b/src/evolution/invariant-check.ts index 8fb0e303..188ade49 100644 --- a/src/evolution/invariant-check.ts +++ b/src/evolution/invariant-check.ts @@ -1,3 +1,4 @@ +import { truncate } from "../shared/strings.ts"; import type { EvolutionConfig } from "./config.ts"; import type { InvariantFailure, InvariantResult, SubprocessSentinel } from "./types.ts"; import type { DirectorySnapshot } from "./versioning.ts"; @@ -411,5 +412,5 @@ function isNearDuplicate(a: string, b: string): boolean { } function truncateForLog(text: string): string { - return text.length > 80 ? `${text.slice(0, 80)}...` : text; + return truncate(text, 80); } diff --git a/src/evolution/reflection-subprocess.ts b/src/evolution/reflection-subprocess.ts index 89190ad6..4db9e129 100644 --- a/src/evolution/reflection-subprocess.ts +++ b/src/evolution/reflection-subprocess.ts @@ -4,6 +4,7 @@ import { query } from "../agent/agent-sdk.ts"; import { getThinkingConfig } from "../agent/thinking-config.ts"; import { buildAgentRuntimeEnv, resolveAgentRuntimeModel } from "../config/providers.ts"; import type { PhantomConfig } from "../config/types.ts"; +import { errorMessage } from "../shared/strings.ts"; import type { EvolutionConfig } from "./config.ts"; import { runInvariantCheck } from "./invariant-check.ts"; import { JUDGE_MODEL_HAIKU, JUDGE_MODEL_OPUS, JUDGE_MODEL_SONNET } from "./judge-models.ts"; @@ -182,7 +183,7 @@ export async function runReflectionSubprocess(input: ReflectionSubprocessInput): abortSignal: controller.signal, }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); queryResult = { responseText: "", costUsd: 0, @@ -350,7 +351,7 @@ export async function runReflectionSubprocess(input: ReflectionSubprocessInput): try { return await runTier("haiku", null); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); try { restoreSnapshot(input.config, snapshot); } catch { @@ -415,7 +416,7 @@ function appendEvolutionLog(config: EvolutionConfig, entry: EvolutionLogEntry): if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); appendFileSync(logPath, `${JSON.stringify(entry)}\n`, "utf-8"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[evolution] Failed to append evolution log: ${msg}`); } } @@ -645,7 +646,7 @@ async function defaultRunner(input: SpawnQueryInput): Promise } } } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { responseText, costUsd, diff --git a/src/index.ts b/src/index.ts index fca36ce6..720f5962 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,7 @@ import { Scheduler } from "./scheduler/service.ts"; import { createSchedulerToolServer } from "./scheduler/tool.ts"; import { getSecretRequest } from "./secrets/store.ts"; import { createSecretToolServer } from "./secrets/tools.ts"; +import { errorMessage } from "./shared/strings.ts"; import { reportAgentReady } from "./tenancy/heartbeat.ts"; import { createBrowserToolServer } from "./ui/browser-mcp.ts"; import { setLoginPageAgentName } from "./ui/login-page.ts"; @@ -168,7 +169,7 @@ async function main(): Promise { `[evolution] Cadence started (cadence=${cadenceConfig.cadenceMinutes}min, demand_trigger=${cadenceConfig.demandTriggerDepth})`, ); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[evolution] Failed to initialize: ${msg}. Running without self-evolution.`); } @@ -218,7 +219,7 @@ async function main(): Promise { } }) .catch((err: unknown) => { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.warn(`[feedback] Evolution from feedback failed: ${errMsg}`); }); } @@ -309,7 +310,7 @@ async function main(): Promise { `[mcp] MCP server initialized (dynamic tools + scheduler + reflective + web UI + secrets + preview + browser${emailStatus} wired to agent)`, ); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[mcp] Failed to initialize MCP server: ${msg}. Running without MCP.`); } @@ -527,7 +528,7 @@ async function main(): Promise { }); console.log("[push] Web Push notifications initialized"); } catch (err: unknown) { - const pushMsg = err instanceof Error ? err.message : String(err); + const pushMsg = errorMessage(err); console.warn(`[push] Failed to initialize: ${pushMsg}. Running without push notifications.`); } @@ -621,7 +622,7 @@ async function main(): Promise { removeReaction: (emoji) => sc.removeReaction(ch, mts, emoji), }, onError: (err) => { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.warn(`[slack] Reaction error: ${errMsg}`); }, }); @@ -643,7 +644,7 @@ async function main(): Promise { await sc.updateWithFeedback(ch, messageId, text); }, onError: (err) => { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.warn(`[slack] Progress stream error: ${errMsg}`); }, }); @@ -753,7 +754,7 @@ async function main(): Promise { } }) .catch((err: unknown) => { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.warn(`[memory] Consolidation failed: ${errMsg}`); }); } @@ -786,7 +787,7 @@ async function main(): Promise { } }) .catch((err: unknown) => { - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = errorMessage(err); console.warn(`[evolution] Post-session evolution failed: ${errMsg}`); }); } @@ -835,7 +836,7 @@ async function main(): Promise { if (!slackChannel) { const { handleFirstRun } = await import("./chat/first-run.ts"); handleFirstRun(db, config).catch((err: unknown) => { - const firstRunMsg = err instanceof Error ? err.message : String(err); + const firstRunMsg = errorMessage(err); console.warn(`[first-run] Failed: ${firstRunMsg}`); }); } @@ -853,7 +854,7 @@ async function main(): Promise { const nt = notificationTriggers; scheduler.onJobComplete((jobName, status) => { nt.onScheduledJobResult(jobName, status).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[push] Scheduler trigger failed: ${msg}`); }); }); @@ -880,7 +881,7 @@ async function main(): Promise { // Non-blocking: wake the agent, let it decide what to say (Cardinal Rule) runtime.handleMessage("slack", conversationId, prompt).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.warn(`[secrets] Failed to wake agent after secret save: ${msg}`); }); }); @@ -987,7 +988,7 @@ async function main(): Promise { } main().catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[phantom] Fatal: ${msg}`); process.exit(1); }); diff --git a/src/mcp/audit.ts b/src/mcp/audit.ts index 154e5583..a0a5201d 100644 --- a/src/mcp/audit.ts +++ b/src/mcp/audit.ts @@ -1,4 +1,5 @@ import type { Database } from "bun:sqlite"; +import { truncate } from "../shared/strings.ts"; import type { AuditEntry } from "./types.ts"; const CREATE_TABLE = `CREATE TABLE IF NOT EXISTS mcp_audit ( @@ -57,8 +58,3 @@ export class AuditLogger { .all(clientName, limit) as AuditEntry[]; } } - -function truncate(str: string, maxLen: number): string { - if (str.length <= maxLen) return str; - return `${str.slice(0, maxLen - 3)}...`; -} diff --git a/src/memory-files/storage.ts b/src/memory-files/storage.ts index ba6a55e7..e860b9df 100644 --- a/src/memory-files/storage.ts +++ b/src/memory-files/storage.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join, relative as relPath } from "node:path"; +import { errorMessage } from "../shared/strings.ts"; import { EXCLUDED_TOP_DIRS, EXCLUDED_TOP_FILES, @@ -148,7 +149,7 @@ export function readMemoryFile(relative: string): ReadResult { try { absolute = resolvePhantomConfigMemoryPath(relative).absolute; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(absolute)) { @@ -160,7 +161,7 @@ export function readMemoryFile(relative: string): ReadResult { content = readFileSync(absolute, "utf-8"); stats = statSync(absolute); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to read memory file: ${msg}` }; } const tail = relative.slice(PHANTOM_CONFIG_VIRTUAL_PREFIX.length); @@ -182,7 +183,7 @@ export function readMemoryFile(relative: string): ReadResult { try { absolute = resolveMemoryFilePath(relative).absolute; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(absolute)) { @@ -194,7 +195,7 @@ export function readMemoryFile(relative: string): ReadResult { content = readFileSync(absolute, "utf-8"); stats = statSync(absolute); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to read memory file: ${msg}` }; } return { @@ -248,7 +249,7 @@ export function writeMemoryFile(input: WriteInput, options: { mustExist: boolean try { absolute = resolveMemoryFilePath(input.path).absolute; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } @@ -269,7 +270,7 @@ export function writeMemoryFile(input: WriteInput, options: { mustExist: boolean try { writeAtomic(absolute, input.content); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to write memory file: ${msg}` }; } @@ -293,7 +294,7 @@ export function deleteMemoryFile(relative: string): DeleteResult { try { absolute = resolveMemoryFilePath(relative).absolute; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(absolute)) { @@ -308,7 +309,7 @@ export function deleteMemoryFile(relative: string): DeleteResult { try { rmSync(absolute, { force: true }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to delete memory file: ${msg}` }; } return { ok: true, deleted: relative, previousContent }; diff --git a/src/scheduler/service.ts b/src/scheduler/service.ts index 807fd231..215e3e5c 100644 --- a/src/scheduler/service.ts +++ b/src/scheduler/service.ts @@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite"; import { randomUUID } from "node:crypto"; import type { AgentRuntime } from "../agent/runtime.ts"; import type { SlackTransport } from "../channels/slack-transport.ts"; +import { errorMessage } from "../shared/strings.ts"; import { validateCreateInput } from "./create-validation.ts"; import { executeJob } from "./executor.ts"; import { type SchedulerHealthSummary, computeHealthSummary } from "./health.ts"; @@ -191,7 +192,7 @@ export class Scheduler { try { jobs.push(rowToJob(row)); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[scheduler] Failed to parse row ${row.id} (${row.name ?? "?"}): ${msg}`); } } @@ -204,7 +205,7 @@ export class Scheduler { try { return rowToJob(row); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[scheduler] Failed to parse row ${row.id}: ${msg}`); return null; } @@ -297,7 +298,7 @@ export class Scheduler { try { job = rowToJob(row); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[scheduler] Skipping unparsable row ${row.id}: ${msg}`); continue; } @@ -306,7 +307,7 @@ export class Scheduler { const status = result.startsWith("Error:") ? "error" : "completed"; this.fireJobCompleteCallbacks(job.name, status); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[scheduler] Job ${job.id} (${job.name}) failed: ${msg}`); this.fireJobCompleteCallbacks(job.name, "error"); } @@ -338,7 +339,7 @@ export class Scheduler { private notifyOwner(text: string): void { if (this.slackChannel && this.ownerUserId) { this.slackChannel.sendDm(this.ownerUserId, text).catch((err: unknown) => { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); console.error(`[scheduler] Failed to notify owner: ${msg}`); }); return; diff --git a/src/secrets/form-page.ts b/src/secrets/form-page.ts index 88e9e1e4..3d3bad10 100644 --- a/src/secrets/form-page.ts +++ b/src/secrets/form-page.ts @@ -1,3 +1,4 @@ +import { escapeHtml } from "../shared/strings.ts"; import type { SecretField, SecretRequest } from "./store.ts"; /** @@ -309,10 +310,4 @@ function buildFieldCard(field: SecretField): string { `; } -function escapeHtml(text: string): string { - return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); -} - -function escapeAttr(text: string): string { - return text.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); -} +const escapeAttr = escapeHtml; diff --git a/src/shared/strings.ts b/src/shared/strings.ts new file mode 100644 index 00000000..c6bd6ee2 --- /dev/null +++ b/src/shared/strings.ts @@ -0,0 +1,31 @@ +/** + * Shared string helpers used across subsystems. Each function is intentionally + * small and side-effect-free so callers can import only what they need without + * pulling in heavy dependencies. + */ + +/** HTML entity escape for the five characters that matter in quoted attributes and element content. */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Truncate a string to `max` characters, appending "..." when shortened. */ +export function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 3))}...`; +} + +/** Type guard for plain objects (not null, not arrays). */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Extract an error message from an unknown catch value. */ +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/skills/storage.ts b/src/skills/storage.ts index d438b266..93ea0d69 100644 --- a/src/skills/storage.ts +++ b/src/skills/storage.ts @@ -6,6 +6,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { errorMessage } from "../shared/strings.ts"; import { MAX_BODY_BYTES, type ParseResult, @@ -93,7 +94,7 @@ export function listSkills(): ListResult { try { entries = readdirSync(root); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { skills, errors: [{ name: "", error: `Failed to list skills root: ${msg}` }] }; } @@ -111,7 +112,7 @@ export function listSkills(): ListResult { raw = readFileSync(skillFile, "utf-8"); stats = statSync(skillFile); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); errors.push({ name: entry, error: `Failed to read: ${msg}` }); continue; } @@ -147,7 +148,7 @@ export function readSkill(name: string): ReadResult { try { file = resolveUserSkillPath(name).file; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(file)) { @@ -159,7 +160,7 @@ export function readSkill(name: string): ReadResult { raw = readFileSync(file, "utf-8"); stats = statSync(file); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to read skill: ${msg}` }; } const parsed: ParseResult = parseFrontmatter(raw); @@ -221,7 +222,7 @@ export function writeSkill(input: WriteInput, options: { mustExist: boolean }): try { file = resolveUserSkillPath(name).file; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } @@ -250,7 +251,7 @@ export function writeSkill(input: WriteInput, options: { mustExist: boolean }): try { writeAtomic(file, serialized); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to write skill: ${msg}` }; } @@ -276,7 +277,7 @@ export function deleteSkill(name: string): DeleteResult { dir = r.dir; file = r.file; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(file)) { @@ -299,7 +300,7 @@ export function deleteSkill(name: string): DeleteResult { // directory not empty or missing; non-fatal } } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to delete skill: ${msg}` }; } return { ok: true, deleted: name, previousBody }; diff --git a/src/subagents/storage.ts b/src/subagents/storage.ts index 1e603112..a2b51220 100644 --- a/src/subagents/storage.ts +++ b/src/subagents/storage.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { errorMessage } from "../shared/strings.ts"; import { MAX_BODY_BYTES, type ParseResult, @@ -80,7 +81,7 @@ export function listSubagents(): ListResult { try { entries = readdirSync(root); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { subagents, errors: [{ name: "", error: `Failed to list subagents root: ${msg}` }] }; } @@ -97,7 +98,7 @@ export function listSubagents(): ListResult { raw = readFileSync(file, "utf-8"); stats = statSync(file); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); errors.push({ name, error: `Failed to read: ${msg}` }); continue; } @@ -125,7 +126,7 @@ export function readSubagent(name: string): ReadResult { try { file = resolveUserSubagentPath(name).file; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(file)) { @@ -137,7 +138,7 @@ export function readSubagent(name: string): ReadResult { raw = readFileSync(file, "utf-8"); stats = statSync(file); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to read subagent: ${msg}` }; } const parsed: ParseResult = parseFrontmatter(raw); @@ -200,7 +201,7 @@ export function writeSubagent(input: WriteInput, options: { mustExist: boolean } try { file = resolveUserSubagentPath(name).file; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } @@ -229,7 +230,7 @@ export function writeSubagent(input: WriteInput, options: { mustExist: boolean } try { writeAtomic(file, serialized); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to write subagent: ${msg}` }; } @@ -252,7 +253,7 @@ export function deleteSubagent(name: string): DeleteResult { try { file = resolveUserSubagentPath(name).file; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 422, error: msg }; } if (!existsSync(file)) { @@ -269,7 +270,7 @@ export function deleteSubagent(name: string): DeleteResult { try { rmSync(file, { force: true }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, status: 500, error: `Failed to delete subagent: ${msg}` }; } return { ok: true, deleted: name, previousBody }; diff --git a/src/ui/api/phantom-config-storage.ts b/src/ui/api/phantom-config-storage.ts index 495a70a9..96800dd6 100644 --- a/src/ui/api/phantom-config-storage.ts +++ b/src/ui/api/phantom-config-storage.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from "node:fs"; import { parse as parseYaml } from "yaml"; import { EvolutionUiConfigSchema, PermissionsConfigSchema } from "../../config/schemas.ts"; +import { errorMessage } from "../../shared/strings.ts"; import type { AppliedChange, PhantomConfigForUi, @@ -39,14 +40,14 @@ export function readYamlFile(path: string): ReadResult> try { raw = readFileSync(path, "utf-8"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, error: `Failed to read ${path}: ${msg}` }; } let parsed: unknown; try { parsed = parseYaml(raw); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, error: `Invalid YAML at ${path}: ${msg}` }; } if (parsed == null) return { ok: true, value: {} }; @@ -62,14 +63,14 @@ export function readJsonFile(path: string): ReadResult> try { raw = readFileSync(path, "utf-8"); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, error: `Failed to read ${path}: ${msg}` }; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { ok: false, error: `Invalid JSON at ${path}: ${msg}` }; } if (parsed == null) return { ok: true, value: {} }; diff --git a/src/ui/api/plugins.ts b/src/ui/api/plugins.ts index 4bff53b4..c90bfaa3 100644 --- a/src/ui/api/plugins.ts +++ b/src/ui/api/plugins.ts @@ -19,6 +19,7 @@ import { listPluginAudit, recordPluginInstall } from "../../plugins/audit.ts"; import { type FetchMarketplaceFn, getCatalog } from "../../plugins/marketplace.ts"; import { OFFICIAL_MARKETPLACE_ID, formatPluginKey, parsePluginKey } from "../../plugins/paths.ts"; import { installPlugin, listEnabledPlugins, uninstallPlugin } from "../../plugins/settings-io.ts"; +import { errorMessage } from "../../shared/strings.ts"; export type PluginsApiDeps = { db: Database; @@ -43,7 +44,7 @@ async function readJson(req: Request): Promise { try { return await req.json(); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return { __error: `Invalid JSON body: ${msg}` }; } } @@ -121,7 +122,7 @@ export async function handlePluginsApi(req: Request, url: URL, deps: PluginsApiD from_stale_cache: catalog.from_stale_cache, }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return json({ error: msg }, { status: 502 }); } } @@ -150,7 +151,7 @@ export async function handlePluginsApi(req: Request, url: URL, deps: PluginsApiD try { key = formatPluginKey(plugin, marketplace ?? OFFICIAL_MARKETPLACE_ID); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return json({ error: msg }, { status: 422 }); } @@ -176,7 +177,7 @@ export async function handlePluginsApi(req: Request, url: URL, deps: PluginsApiD sourceType = entry.source_type; sourceUrl = entry.source_url; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return json({ error: `Marketplace unreachable: ${msg}` }, { status: 502 }); } @@ -244,7 +245,7 @@ export async function handlePluginsApi(req: Request, url: URL, deps: PluginsApiD })), }); } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); + const msg = errorMessage(err); return json({ error: `Marketplace unreachable: ${msg}` }, { status: 502 }); } } diff --git a/src/ui/html.ts b/src/ui/html.ts index b7749218..28e055cb 100644 --- a/src/ui/html.ts +++ b/src/ui/html.ts @@ -1,11 +1,3 @@ -// Minimal HTML entity escape for the five characters that matter in -// quoted attributes and element content. Used by server-side page -// generators to defend against operator-supplied brand strings. -export function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} +// Re-export the canonical escapeHtml from shared/strings.ts so existing +// callers that import from "src/ui/html.ts" continue to work unchanged. +export { escapeHtml } from "../shared/strings.ts";