diff --git a/AGENTS.md b/AGENTS.md index 70f2165..7a8411d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,11 @@ Keep changes focused. Run the narrowest relevant tests while iterating, then run the full CI contract (`npm run ci`) before merge. Never weaken a check to make a change pass. +Modify the module that owns the behavior; extract a cohesive seam when that +keeps ordinary work local. Avoid unrelated refactors, and pause before a change +spreads across several domains. Use `npm run architecture:report` to spot legacy +hotspots and ratchet a budget down after making one smaller. + Every handoff must briefly name changed files, checks run and their results, known risks, rollback steps, and whether stable or any external system was touched. diff --git a/config/module-budgets.json b/config/module-budgets.json new file mode 100644 index 0000000..ca28c04 --- /dev/null +++ b/config/module-budgets.json @@ -0,0 +1,39 @@ +{ + "thresholds": { + "lines": 800, + "fanIn": 8, + "fanOut": 20 + }, + "legacy": { + "lines": { + "src/client/app.ts": 3321, + "src/client/channel.ts": 1208, + "src/client/cowork.ts": 858, + "src/client/routing.ts": 864, + "src/client/settings.ts": 801, + "src/server/agents.ts": 1353, + "src/server/bots.ts": 2008, + "src/server/channel-computers.ts": 2130, + "src/server/db.ts": 1155, + "src/server/index.ts": 2234, + "src/server/routing.ts": 1293 + }, + "fanIn": { + "scripts/platform-acceptance-lib.mjs": 9, + "src/client/api.ts": 11, + "src/client/dom.ts": 9, + "src/server/agents.ts": 10, + "src/server/db.ts": 30, + "src/server/store.ts": 9 + }, + "fanOut": { + "src/server/bots.ts": 23, + "src/server/index.ts": 33 + }, + "cycles": [ + ["src/client/app.ts", "src/client/channel.ts", "src/client/cowork.ts", "src/client/settings.ts", "src/client/term.ts"], + ["src/server/bots.ts", "src/server/followups.ts"], + ["src/server/routing.ts", "src/server/skills.ts"] + ] + } +} diff --git a/docs/module-map.md b/docs/module-map.md new file mode 100644 index 0000000..6fcb0c5 --- /dev/null +++ b/docs/module-map.md @@ -0,0 +1,40 @@ +# Developer module map + +Use this map to find the smallest owning module before editing a hotspot. The +architecture report is advisory: `npm run architecture:report` lists large +modules, fan-in/fan-out, and import cycles without failing legacy debt. Budgets +live in `config/module-budgets.json`; lower a legacy value after an extraction, +and do not raise it for unrelated growth. + +## Client + +| Area | Owning module | Notes | +| --- | --- | --- | +| App boot, workspace state, navigation, transcript orchestration | `src/client/app.ts` | Coordinator. Keep pure display rules out of it. | +| Thread/progress labels, tool-body parsing, token/countdown formatting | `src/client/thread-formatters.ts` | Pure, directly testable presentation contract. | +| API transport and shared client response types | `src/client/api.ts` | Network boundary used across client features. | +| Channel files, notes, board, activity, memory, settings surfaces | `src/client/channel.ts` | Channel-level surface controller. | +| Cowork editors and collaboration UI | `src/client/cowork.ts`, `src/client/cowork-editors.ts` | Collaborative document domain. | +| Routing, onboarding, settings, terminal, mobile | Same-named modules in `src/client/` | Feature owners; avoid routing their changes through `app.ts` unless orchestration is required. | + +## Server + +| Area | Owning module | Notes | +| --- | --- | --- | +| Server lifecycle, REST/WS route orchestration | `src/server/index.ts` | Router/coordinator; reusable HTTP policy belongs in `http.ts`. | +| JSON/body limits, security headers, mobile CORS, rate limiting, MIME map | `src/server/http.ts` | Narrow HTTP boundary with fail-closed characterization tests. | +| Agent turn orchestration, prompts, tool catalog/execution | `src/server/bots.ts` | Runtime coordinator. Pure tool result/audit wording belongs in `bot-output.ts`. | +| Tool completion fallbacks, action summaries, command-result status | `src/server/bot-output.ts` | Pure user-visible/audit formatting contract. | +| Per-channel computer lifecycle and runtime backends | `src/server/channel-computers.ts` | Apple/OCI/native/mock provisioning, execution, mirror, readiness, fleet care. | +| Durable agent/channel worlds | `src/server/agents.ts` | Provision/archive/restore/delete, workspaces, thread helpers. | +| Database connection, additive migrations, seed/recovery | `src/server/db.ts` | High fan-in compatibility boundary. No migration or data-layout change belongs in a refactor phase. | +| Routing, storage views, events, follow-ups, skills, workflows | Same-named modules in `src/server/` | Domain owners called by the coordinators above. | + +## Verification ownership + +`test/phase6-modules.mjs` directly characterizes the three extracted contracts. +Runtime integration remains covered by `test/autonomy-platform.mjs`, +`test/sweep-fleet-telemetry.mjs`, browser/native suites, and the full +`npm run ci` contract. Delivery Phases 1–5 retain their named `test:phase*` +commands; modular work must not alter their artifact, promotion, or release +semantics. diff --git a/package.json b/package.json index 5df8d88..1fe6799 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,12 @@ "test:phase4": "node --test test/phase4-platform-acceptance.mjs test/phase3-promotion.mjs", "test:phase5": "node --test test/phase5-artifacts.mjs test/phase4-platform-acceptance.mjs test/phase3-promotion.mjs test/site-stable-manifest.mjs", "test:fast": "node scripts/run-fast-tests.mjs", + "test:phase6": "node --test test/phase6-modules.mjs test/autonomy-platform.mjs test/sweep-fleet-telemetry.mjs", "delivery:status": "node scripts/delivery-status.mjs", "stable:status": "node scripts/promotion-status.mjs", "cleanup:report": "node scripts/cleanup-report.mjs", "artifacts:report": "node scripts/artifact-size-report.mjs", + "architecture:report": "node scripts/module-architecture-report.mjs", "benchmark:autonomy": "node scripts/autonomy-benchmark.mjs", "helm": "node scripts/1helm-cli.mjs", "test:live": "node test/live-smoke.mjs", diff --git a/scripts/module-architecture-report.mjs b/scripts/module-architecture-report.mjs new file mode 100644 index 0000000..7ba8d6c --- /dev/null +++ b/scripts/module-architecture-report.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, extname, join, normalize, relative, resolve } from "node:path"; + +process.stdout.on("error", (error) => { + if (error.code === "EPIPE") process.exit(0); + throw error; +}); + +const root = resolve(import.meta.dirname, ".."); +const config = JSON.parse(readFileSync(join(root, "config", "module-budgets.json"), "utf8")); +const sourceRoots = ["src", "scripts", "cloudflare", "desktop"]; +const extensions = new Set([".ts", ".mjs", ".js", ".cjs"]); +const generated = new Set(["desktop/photon-sidecar.bundle.mjs"]); +const files = []; + +function collect(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) collect(path); + else if (extensions.has(extname(path)) && !generated.has(relative(root, path))) files.push(relative(root, path)); + } +} +for (const directory of sourceRoots) if (existsSync(join(root, directory))) collect(join(root, directory)); +files.sort(); + +const fileSet = new Set(files); +const graph = new Map(files.map((file) => [file, new Set()])); +const importPattern = /(?:import|export)\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g; + +function resolveImport(from, specifier) { + if (!specifier.startsWith(".")) return null; + const base = normalize(join(dirname(from), specifier)); + const candidates = [base, ...[".ts", ".mjs", ".js", ".cjs"].map((suffix) => base + suffix), + ...["index.ts", "index.mjs", "index.js", "index.cjs"].map((name) => join(base, name))]; + return candidates.find((candidate) => fileSet.has(candidate)) || null; +} + +const lines = new Map(); +for (const file of files) { + const source = readFileSync(join(root, file), "utf8"); + lines.set(file, source === "" ? 0 : source.split(/\r?\n/).length - (source.endsWith("\n") ? 1 : 0)); + for (const match of source.matchAll(importPattern)) { + const target = resolveImport(file, match[1] || match[2]); + if (target) graph.get(file).add(target); + } +} + +const fanIn = new Map(files.map((file) => [file, 0])); +for (const targets of graph.values()) for (const target of targets) fanIn.set(target, fanIn.get(target) + 1); + +let nextIndex = 0; +const indices = new Map(), low = new Map(), stack = [], stacked = new Set(), cycles = []; +function connect(file) { + indices.set(file, nextIndex); low.set(file, nextIndex); nextIndex++; stack.push(file); stacked.add(file); + for (const target of graph.get(file)) { + if (!indices.has(target)) { connect(target); low.set(file, Math.min(low.get(file), low.get(target))); } + else if (stacked.has(target)) low.set(file, Math.min(low.get(file), indices.get(target))); + } + if (low.get(file) !== indices.get(file)) return; + const component = []; + while (stack.length) { + const item = stack.pop(); stacked.delete(item); component.push(item); + if (item === file) break; + } + if (component.length > 1 || graph.get(component[0]).has(component[0])) cycles.push(component.sort()); +} +for (const file of files) if (!indices.has(file)) connect(file); + +function status(kind, file, value) { + const budget = config.legacy[kind]?.[file]; + if (budget == null) return "NEW"; + return value > budget ? "REGRESSION" : "legacy"; +} + +const large = files.filter((file) => lines.get(file) > config.thresholds.lines) + .sort((a, b) => lines.get(b) - lines.get(a)); +const highFanIn = files.filter((file) => fanIn.get(file) > config.thresholds.fanIn) + .sort((a, b) => fanIn.get(b) - fanIn.get(a)); +const highFanOut = files.filter((file) => graph.get(file).size > config.thresholds.fanOut) + .sort((a, b) => graph.get(b).size - graph.get(a).size); + +process.stdout.write("1Helm first-party module architecture report (advisory only)\n"); +process.stdout.write(`Scanned ${files.length} modules and ${[...graph.values()].reduce((sum, targets) => sum + targets.size, 0)} internal import edges.\n`); +process.stdout.write(`Budgets: >${config.thresholds.lines} lines, >${config.thresholds.fanIn} importers, >${config.thresholds.fanOut} internal imports.\n\n`); +process.stdout.write("Large modules\n"); +for (const file of large) process.stdout.write(` [${status("lines", file, lines.get(file))}] ${String(lines.get(file)).padStart(5)} ${file}\n`); +process.stdout.write("High fan-in\n"); +for (const file of highFanIn) process.stdout.write(` [${status("fanIn", file, fanIn.get(file))}] ${String(fanIn.get(file)).padStart(5)} ${file}\n`); +process.stdout.write("High fan-out\n"); +for (const file of highFanOut) process.stdout.write(` [${status("fanOut", file, graph.get(file).size)}] ${String(graph.get(file).size).padStart(5)} ${file}\n`); +process.stdout.write("Import cycles\n"); +const knownCycles = new Set(config.legacy.cycles.map((cycle) => [...cycle].sort().join(" -> "))); +if (!cycles.length) process.stdout.write(" none\n"); +for (const cycle of cycles) { + const key = cycle.join(" -> "); + process.stdout.write(` [${knownCycles.has(key) ? "legacy" : "NEW"}] ${key}\n`); +} +const flags = [ + ...large.filter((file) => status("lines", file, lines.get(file)) !== "legacy"), + ...highFanIn.filter((file) => status("fanIn", file, fanIn.get(file)) !== "legacy"), + ...highFanOut.filter((file) => status("fanOut", file, graph.get(file).size) !== "legacy"), +].length + cycles.filter((cycle) => !knownCycles.has(cycle.join(" -> "))).length; +process.stdout.write(`\n${flags ? `Attention: ${flags} new or regressed budget flag(s).` : "No new or regressed budget flags."}\n`); +process.stdout.write("This report never changes the exit status. Ratchet a legacy budget down after an extraction; do not raise it to accommodate unrelated growth.\n"); diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 9c59c9d..bc49b17 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -12,6 +12,7 @@ const env = { ...process.env, NODE_ENV: "test", MNEMOSYNE_PYTHON: prepared.runti const suites = [ ["test/native-world.mjs"], ["--test", + "test/phase6-modules.mjs", "test/routing.mjs", "test/routing-disabled-account.mjs", "test/routing-antigravity.mjs", "test/desktop.mjs", "test/update-service.mjs", "test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/event-loop-unblocking.mjs", "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", diff --git a/src/client/app.ts b/src/client/app.ts index 70b6a9d..4fc3292 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -8,6 +8,19 @@ import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTermi import { openCreateChannel, renderActivity, renderBoard, renderChannelSettings, renderFiles, renderGlobalThreads, renderMemory, renderNotes, renderTexts, renderThreads, type ChannelView } from "./channel.ts"; import { renderCowork, setActiveCoworkChannel, stageCoworkPath } from "./cowork.ts"; import { apiUrl, finishNativeLaunch, forgetMobileServer, getServerOrigin, isNativeMobile, serverAssetUrl } from "./mobile.ts"; +import { + formatThreadFollowupCountdown, + progressCounts, + progressPreviewLine, + progressStatusLabel, + progressStatusTone, + parseToolBody, + stickyThoughtFromProgress, + stickyWorkingLabel, + threadUsageLabel as threadUsageLabelValue, + workingChipLabel, + workingDisplayBody, +} from "./thread-formatters.ts"; /** Per-channel layout bound to the user profile (server user_ui_state). */ type ChannelUiView = { @@ -2295,94 +2308,6 @@ function snapshotProgressOpenState(root: ParentNode | null = document): void { }); } -function progressStatusTone(status: string): string { - if (status === "running") return "animate-pulse bg-amber-400"; - if (status === "failed") return "bg-danger"; - return "bg-ok"; -} - -function progressStatusLabel(status: string): string { - if (status === "running") return "running"; - if (status === "failed") return "failed"; - return "done"; -} - -function humanToolName(raw: string): string { - return raw.replaceAll("_", " ").replace(/\s+/g, " ").trim(); -} - -/** Split tool progress body: "name: input" then optional "\\nresult". */ -function parseToolBody(body: string): { title: string; input: string; output: string } { - const text = body || ""; - const nl = text.indexOf("\n"); - const head = nl >= 0 ? text.slice(0, nl) : text; - const rest = nl >= 0 ? text.slice(nl + 1).trim() : ""; - const colon = head.indexOf(":"); - if (colon < 0) return { title: humanToolName(head) || "tool", input: "", output: rest }; - return { - title: humanToolName(head.slice(0, colon)) || "tool", - input: head.slice(colon + 1).trim(), - output: rest, - }; -} - -function progressPreviewLine(items: AgentProgress[]): string { - // Live current step only (running tool / thinking / status). Do NOT fall back to a - // completed thought — that forced the summary preview to snap after every tool tick. - const running = [...items].reverse().find((item) => item.status === "running") || items[items.length - 1]; - if (!running) return ""; - if (running.kind === "tool") { - const { title, input } = parseToolBody(running.body); - return input ? `${title} · ${input.slice(0, 72)}` : title; - } - if (running.kind === "thinking") { - const line = running.body.trim().split(/\n+/).find(Boolean) || "Thinking…"; - return line.slice(0, 80) + (line.length > 80 ? "…" : ""); - } - return (running.body || "Working…").slice(0, 80); -} - -function stickyThoughtFromProgress(items: AgentProgress[] | undefined): string { - if (!items?.length) return ""; - const thought = [...items].reverse().find((item) => item.kind === "thinking" && item.body.trim()); - return thought?.body.trim() || ""; -} - -/** Replace only the literal "Working…" label — not the whole summary row. */ -function stickyWorkingLabel(items: AgentProgress[] | undefined): string { - const sticky = stickyThoughtFromProgress(items); - if (!sticky) return "Working…"; - const line = sticky.split(/\n+/).find(Boolean) || sticky; - return line.length > 72 ? `${line.slice(0, 72)}…` : line; -} - -/** Body shown while an agent turn is mid-flight — keep last real thought sticky. */ -function workingDisplayBody(m: Message): string { - if (m.body && m.body !== "_Working…_") return m.body; - const sticky = stickyThoughtFromProgress(m.progress); - return sticky || m.body || "_Working…_"; -} - -function workingChipLabel(m: Message): string { - // Same rule as the work-log left label: sticky thought, else Working… - if (m.progress?.length) return stickyWorkingLabel(m.progress); - if (m.body && m.body !== "_Working…_") { - const line = m.body.trim().split(/\n+/).find(Boolean) || "Working…"; - return line.length > 72 ? `${line.slice(0, 72)}…` : line; - } - return "Working…"; -} - -function progressCounts(items: AgentProgress[]): string { - const tools = items.filter((i) => i.kind === "tool").length; - const thoughts = items.filter((i) => i.kind === "thinking").length; - const parts: string[] = []; - if (tools) parts.push(`${tools} tool${tools === 1 ? "" : "s"}`); - if (thoughts) parts.push(`${thoughts} thought${thoughts === 1 ? "" : "s"}`); - if (!parts.length) parts.push(`${items.length} step${items.length === 1 ? "" : "s"}`); - return parts.join(" · "); -} - function progressStepCard(messageId: number, item: AgentProgress): HTMLElement { const key = `${messageId}:${item.id}`; const tone = progressStatusTone(item.status); @@ -2646,22 +2571,8 @@ function closeDockedNotes(): void { renderMain(); } -/** Compact token count for the thread header (1.2k, 340, …). */ -function formatRoughTokens(n: number): string { - const v = Math.max(0, Math.round(Number(n) || 0)); - if (v >= 1_000_000) { - const m = v / 1_000_000; - return `${m >= 10 ? Math.round(m) : m.toFixed(1).replace(/\.0$/, "")}M`; - } - if (v >= 1000) { - const k = v / 1000; - return `${k >= 10 ? Math.round(k) : k.toFixed(1).replace(/\.0$/, "")}k`; - } - return String(v); -} - function threadUsageLabel(usage: ThreadUsage = S.threadUsage): string { - return `Used ${formatRoughTokens(usage.input_tokens)} in · ${formatRoughTokens(usage.output_tokens)} out`; + return threadUsageLabelValue(usage); } function paintThreadCtx(): void { @@ -2673,17 +2584,6 @@ function paintThreadCtx(): void { el.classList.toggle("hidden", !(S.threadUsage.input_tokens || S.threadUsage.output_tokens)); } -function formatThreadFollowupCountdown(dueAt: number, nowMs = Date.now()): string { - const remaining = Math.max(0, Math.floor((dueAt - nowMs) / 1000)); - if (remaining <= 0) return "now"; - const hours = Math.floor(remaining / 3600); - const minutes = Math.floor((remaining % 3600) / 60); - const seconds = remaining % 60; - if (hours) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; - if (minutes) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; - return `${seconds}s`; -} - let threadFollowupTimer: number | null = null; function stopThreadFollowupTicker(): void { if (threadFollowupTimer != null) window.clearInterval(threadFollowupTimer); diff --git a/src/client/thread-formatters.ts b/src/client/thread-formatters.ts new file mode 100644 index 0000000..c397a1f --- /dev/null +++ b/src/client/thread-formatters.ts @@ -0,0 +1,118 @@ +import type { AgentProgress, Message, ThreadUsage } from "./api.ts"; + +/** Pure presentation rules shared by the thread transcript and work log. */ +export function progressStatusTone(status: string): string { + if (status === "running") return "animate-pulse bg-amber-400"; + if (status === "failed") return "bg-danger"; + return "bg-ok"; +} + +export function progressStatusLabel(status: string): string { + if (status === "running") return "running"; + if (status === "failed") return "failed"; + return "done"; +} + +export function humanToolName(raw: string): string { + return raw.replaceAll("_", " ").replace(/\s+/g, " ").trim(); +} + +/** Split tool progress body: "name: input" then optional "\\nresult". */ +export function parseToolBody(body: string): { title: string; input: string; output: string } { + const text = body || ""; + const nl = text.indexOf("\n"); + const head = nl >= 0 ? text.slice(0, nl) : text; + const rest = nl >= 0 ? text.slice(nl + 1).trim() : ""; + const colon = head.indexOf(":"); + if (colon < 0) return { title: humanToolName(head) || "tool", input: "", output: rest }; + return { + title: humanToolName(head.slice(0, colon)) || "tool", + input: head.slice(colon + 1).trim(), + output: rest, + }; +} + +export function progressPreviewLine(items: AgentProgress[]): string { + // Live current step only. Falling back to an old thought makes the preview + // snap backwards after every tool tick. + const running = [...items].reverse().find((item) => item.status === "running") || items[items.length - 1]; + if (!running) return ""; + if (running.kind === "tool") { + const { title, input } = parseToolBody(running.body); + return input ? `${title} · ${input.slice(0, 72)}` : title; + } + if (running.kind === "thinking") { + const line = running.body.trim().split(/\n+/).find(Boolean) || "Thinking…"; + return line.slice(0, 80) + (line.length > 80 ? "…" : ""); + } + return (running.body || "Working…").slice(0, 80); +} + +export function stickyThoughtFromProgress(items: AgentProgress[] | undefined): string { + if (!items?.length) return ""; + const thought = [...items].reverse().find((item) => item.kind === "thinking" && item.body.trim()); + return thought?.body.trim() || ""; +} + +/** Replace only the literal Working label, not the whole summary row. */ +export function stickyWorkingLabel(items: AgentProgress[] | undefined): string { + const sticky = stickyThoughtFromProgress(items); + if (!sticky) return "Working…"; + const line = sticky.split(/\n+/).find(Boolean) || sticky; + return line.length > 72 ? `${line.slice(0, 72)}…` : line; +} + +/** Body shown while an agent turn is mid-flight; keep the last real thought sticky. */ +export function workingDisplayBody(message: Message): string { + if (message.body && message.body !== "_Working…_") return message.body; + const sticky = stickyThoughtFromProgress(message.progress); + return sticky || message.body || "_Working…_"; +} + +export function workingChipLabel(message: Message): string { + if (message.progress?.length) return stickyWorkingLabel(message.progress); + if (message.body && message.body !== "_Working…_") { + const line = message.body.trim().split(/\n+/).find(Boolean) || "Working…"; + return line.length > 72 ? `${line.slice(0, 72)}…` : line; + } + return "Working…"; +} + +export function progressCounts(items: AgentProgress[]): string { + const tools = items.filter((item) => item.kind === "tool").length; + const thoughts = items.filter((item) => item.kind === "thinking").length; + const parts: string[] = []; + if (tools) parts.push(`${tools} tool${tools === 1 ? "" : "s"}`); + if (thoughts) parts.push(`${thoughts} thought${thoughts === 1 ? "" : "s"}`); + if (!parts.length) parts.push(`${items.length} step${items.length === 1 ? "" : "s"}`); + return parts.join(" · "); +} + +/** Compact token count for the thread header (1.2k, 340, …). */ +export function formatRoughTokens(value: number): string { + const rounded = Math.max(0, Math.round(Number(value) || 0)); + if (rounded >= 1_000_000) { + const millions = rounded / 1_000_000; + return `${millions >= 10 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`; + } + if (rounded >= 1000) { + const thousands = rounded / 1000; + return `${thousands >= 10 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}k`; + } + return String(rounded); +} + +export function threadUsageLabel(usage: ThreadUsage): string { + return `Used ${formatRoughTokens(usage.input_tokens)} in · ${formatRoughTokens(usage.output_tokens)} out`; +} + +export function formatThreadFollowupCountdown(dueAt: number, nowMs = Date.now()): string { + const remaining = Math.max(0, Math.floor((dueAt - nowMs) / 1000)); + if (remaining <= 0) return "now"; + const hours = Math.floor(remaining / 3600); + const minutes = Math.floor((remaining % 3600) / 60); + const seconds = remaining % 60; + if (hours) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; + if (minutes) return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + return `${seconds}s`; +} diff --git a/src/server/bot-output.ts b/src/server/bot-output.ts new file mode 100644 index 0000000..54ccbd7 --- /dev/null +++ b/src/server/bot-output.ts @@ -0,0 +1,90 @@ +/** Pure user-facing fallbacks used when a model finishes after a tool call. */ +export function completedToolAnswer(tool: string, result: string): string { + if (tool === "gmail_search") { + try { + const parsed = JSON.parse(result) as { account?: string; query?: string; results?: { from?: string; subject?: string; date?: string; snippet?: string }[] }; + const matches = parsed.results || []; + const lines = matches.map((message, index) => [ + `${index + 1}. **${message.subject || "(no subject)"}**`, + message.from ? `From: ${message.from}` : "", + message.date ? `Date: ${message.date}` : "", + message.snippet || "", + ].filter(Boolean).join(" — ")); + return [`Gmail search completed for **${parsed.account || "the granted account"}**.`, `Query: \`${parsed.query || ""}\``, `Matches: **${matches.length}**.`, ...lines].join("\n\n"); + } catch { return "Gmail search completed, but the model did not produce a final explanation. The result remains available in this session."; } + } + if (tool === "gmail_get") { + try { + const parsed = JSON.parse(result) as { account?: string; from?: string; to?: string; subject?: string; date?: string; body?: string }; + return [`Read the Gmail message in **${parsed.account || "the granted account"}**.`, `**From:** ${parsed.from || ""}`, `**To:** ${parsed.to || ""}`, `**Subject:** ${parsed.subject || ""}`, parsed.date ? `**Date:** ${parsed.date}` : "", "", parsed.body || "(empty body)"].filter(Boolean).join("\n"); + } catch { return "The Gmail message was read, but the model did not produce a final explanation."; } + } + if (tool === "gmail_create_draft") { + try { + const parsed = JSON.parse(result) as { account?: string; draft_id?: string }; + return `Created a Gmail draft in **${parsed.account || "the granted account"}**${parsed.draft_id ? ` (draft ${parsed.draft_id})` : ""}. It was not sent.`; + } catch { return "Created the Gmail draft. It was not sent."; } + } + if (tool === "connect_gmail") { + try { + const parsed = JSON.parse(result) as { accounts?: string[]; setup?: { status?: string; authorization_url?: string; error?: string } }; + if (parsed.setup?.authorization_url) return `Gmail authorization is ready. [Authorize Gmail now](${parsed.setup.authorization_url})\n\nThe callback returns directly to this 1Helm installation. OAuth tokens remain host-owned and sending stays disabled.`; + if (parsed.accounts?.length) return `Connected Gmail accounts: ${parsed.accounts.join(", ")}. Read, search, and draft access is available through 1Helm's host broker; sending is disabled.`; + return parsed.setup?.error || "Gmail has no connected accounts yet. Open Settings → Connections to add the one-time Google OAuth client and authorize an account."; + } catch { return result; } + } + if (["grant_gmail_access", "connect_gmail", "create_channel", "list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations", "run_thread_audit", "run_agent_review", "remember", "search_channel_history", "read_channel_session", "call_skipper", "call_agent", "request_skill", "propose_skill", "create_skill", "search_skill_catalog", "inspect_skill", "install_skill", "invite_agent", "search_web", "inspect_web_source", "attach_web_image", "attach_file", "generate_image", "schedule_followup", "schedule_workflow", "list_workflows", "set_workflow_status"].includes(tool)) return result; + if (tool === "gmail_list_accounts") { + try { + const parsed = JSON.parse(result) as { accounts?: string[] }; + return `Gmail access is available for: ${(parsed.accounts || []).join(", ") || "no accounts"}.`; + } catch { return result; } + } + if (tool === "run_command") return `The command completed.\n\n\`\`\`text\n${result}\n\`\`\``; + return `The ${tool.replaceAll("_", " ")} action completed.\n\n${result}`; +} + +function actionObject(tool: string, input: string, actor: string): string { + const clean = input.replace(/\s+/g, " ").trim(); + if (tool === "create_channel") return clean.split(" — ")[0] || "a channel"; + if (tool === "attach_file") return clean.split(/[\\/]/).at(-1) || "a file"; + if (tool === "call_skipper") return "the host boundary"; + if (tool === "call_agent") return clean.split(":")[0] || "the resident"; + if (tool === "gmail_search") return "granted Gmail"; + if (tool === "gmail_get") return "a granted Gmail message"; + if (tool === "gmail_create_draft") return "a Gmail draft"; + if (tool === "run_command") return actor === "skipper" ? "the host workspace" : "the resident workspace"; + if (tool === "schedule_followup") return "a durable wake"; + if (tool === "schedule_workflow") return "a recurring workflow"; + if (tool === "install_skill") return clean || "a catalog skill"; + if (tool === "search_web") return clean || "the public web"; + if (tool === "inspect_web_source") return clean || "a public HTTPS source"; + if (tool === "attach_web_image") return clean || "a sourced web image"; + return clean.length && clean.length <= 96 ? clean : tool.replaceAll("_", " "); +} + +function actionVerb(tool: string): string { + const verbs: Record = { + run_command: "Ran work in", create_channel: "Created", remember: "Recorded", attach_file: "Attached", + call_skipper: "Called Skipper across", call_agent: "Handed work back to", invite_agent: "Invited", + request_skill: "Requested", propose_skill: "Crystallized", create_skill: "Created", + search_skill_catalog: "Searched", inspect_skill: "Inspected", search_web: "Searched", + search_channel_history: "Searched", read_channel_session: "Read", inspect_web_source: "Inspected", + attach_web_image: "Attached", install_skill: "Installed", grant_gmail_access: "Granted", + gmail_list_accounts: "Listed", gmail_search: "Searched", gmail_get: "Read", gmail_create_draft: "Created", + schedule_followup: "Scheduled", schedule_workflow: "Scheduled", list_workflows: "Listed", + set_workflow_status: "Updated", generate_image: "Generated", ask_user: "Opened", + }; + return verbs[tool] || "Used"; +} + +export function actionSummary(tool: string, input: string, status: string, actor: string): string { + const outcome = status === "failed" ? "failed" : status === "running" ? "working" : "complete"; + return `${actionVerb(tool)} ${actionObject(tool, input, actor)} → ${outcome}.`; +} + +export function toolActionStatus(result: string): "failed" | "running" | "complete" { + if (/^Error:/i.test(result) || /^status=failed(?:\n|$)/i.test(result)) return "failed"; + if (/^status=running(?:\n|$)/i.test(result)) return "running"; + return "complete"; +} diff --git a/src/server/bots.ts b/src/server/bots.ts index 4e507d0..f27e94a 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -50,6 +50,9 @@ import { fetchPublicWebImage } from "./web-source.ts"; import { searchWeb } from "./web-search.ts"; import { readChannelThread, searchChannelHistory } from "./history.ts"; import { coworkContextFromRootBody, coworkFormatContract, enforceCoworkCommandOutput, snapshotCoworkSurface } from "./cowork-contract.ts"; +import { actionSummary, completedToolAnswer, toolActionStatus } from "./bot-output.ts"; + +export { toolActionStatus } from "./bot-output.ts"; type ChatMsg = { role: string; content: string; tool_calls?: ToolCall[]; tool_call_id?: string; name?: string }; type ToolCall = { id: string; type: "function"; function: { name: string; arguments: string } }; @@ -71,51 +74,6 @@ const turnLane = (botId: number, channelId: number, threadRootId: number): strin const meaningfulAnswer = (value: string): boolean => value.replace(/[\s*_~`#>\-[\](){}|.!?,:;]+/g, "").length > 0; -function completedToolAnswer(tool: string, result: string): string { - if (tool === "gmail_search") { - try { - const parsed = JSON.parse(result) as { account?: string; query?: string; results?: { from?: string; subject?: string; date?: string; snippet?: string }[] }; - const matches = parsed.results || []; - const lines = matches.map((message, index) => [ - `${index + 1}. **${message.subject || "(no subject)"}**`, - message.from ? `From: ${message.from}` : "", - message.date ? `Date: ${message.date}` : "", - message.snippet || "", - ].filter(Boolean).join(" — ")); - return [`Gmail search completed for **${parsed.account || "the granted account"}**.`, `Query: \`${parsed.query || ""}\``, `Matches: **${matches.length}**.`, ...lines].join("\n\n"); - } catch { return "Gmail search completed, but the model did not produce a final explanation. The result remains available in this session."; } - } - if (tool === "gmail_get") { - try { - const parsed = JSON.parse(result) as { account?: string; from?: string; to?: string; subject?: string; date?: string; body?: string }; - return [`Read the Gmail message in **${parsed.account || "the granted account"}**.`, `**From:** ${parsed.from || ""}`, `**To:** ${parsed.to || ""}`, `**Subject:** ${parsed.subject || ""}`, parsed.date ? `**Date:** ${parsed.date}` : "", "", parsed.body || "(empty body)"].filter(Boolean).join("\n"); - } catch { return "The Gmail message was read, but the model did not produce a final explanation."; } - } - if (tool === "gmail_create_draft") { - try { - const parsed = JSON.parse(result) as { account?: string; draft_id?: string }; - return `Created a Gmail draft in **${parsed.account || "the granted account"}**${parsed.draft_id ? ` (draft ${parsed.draft_id})` : ""}. It was not sent.`; - } catch { return "Created the Gmail draft. It was not sent."; } - } - if (tool === "connect_gmail") { - try { - const parsed = JSON.parse(result) as { accounts?: string[]; setup?: { status?: string; authorization_url?: string; error?: string } }; - if (parsed.setup?.authorization_url) return `Gmail authorization is ready. [Authorize Gmail now](${parsed.setup.authorization_url})\n\nThe callback returns directly to this 1Helm installation. OAuth tokens remain host-owned and sending stays disabled.`; - if (parsed.accounts?.length) return `Connected Gmail accounts: ${parsed.accounts.join(", ")}. Read, search, and draft access is available through 1Helm's host broker; sending is disabled.`; - return parsed.setup?.error || "Gmail has no connected accounts yet. Open Settings → Connections to add the one-time Google OAuth client and authorize an account."; - } catch { return result; } - } - if (["grant_gmail_access", "connect_gmail", "create_channel", "list_channels", "inspect_channel", "archive_channel", "restore_channel", "delete_channel", "inspect_fleet", "care_for_channel_computer", "list_obligations", "run_thread_audit", "run_agent_review", "remember", "search_channel_history", "read_channel_session", "call_skipper", "call_agent", "request_skill", "propose_skill", "create_skill", "search_skill_catalog", "inspect_skill", "install_skill", "invite_agent", "search_web", "inspect_web_source", "attach_web_image", "attach_file", "generate_image", "schedule_followup", "schedule_workflow", "list_workflows", "set_workflow_status"].includes(tool)) return result; - if (tool === "gmail_list_accounts") { - try { - const parsed = JSON.parse(result) as { accounts?: string[] }; - return `Gmail access is available for: ${(parsed.accounts || []).join(", ") || "no accounts"}.`; - } catch { return result; } - } - if (tool === "run_command") return `The command completed.\n\n\`\`\`text\n${result}\n\`\`\``; - return `The ${tool.replaceAll("_", " ")} action completed.\n\n${result}`; -} - export function cancelChannelTurns(channelId: number): void { for (const turn of activeTurns.get(channelId) || []) { turn.controller.abort("channel-lifecycle"); @@ -875,65 +833,6 @@ function setStatus(agent: RuntimeAgent | undefined, channelId: number, status: s broadcastToChannel(channelId, { type: "agent_status", channelId, agentId: agent.id, status }); } -function actionObject(tool: string, input: string, actor: string): string { - const clean = input.replace(/\s+/g, " ").trim(); - if (tool === "create_channel") return clean.split(" — ")[0] || "a channel"; - if (tool === "attach_file") return clean.split(/[\\/]/).at(-1) || "a file"; - if (tool === "call_skipper") return "the host boundary"; - if (tool === "call_agent") return clean.split(":")[0] || "the resident"; - if (tool === "gmail_search") return "granted Gmail"; - if (tool === "gmail_get") return "a granted Gmail message"; - if (tool === "gmail_create_draft") return "a Gmail draft"; - if (tool === "run_command") return actor === "skipper" ? "the host workspace" : "the resident workspace"; - if (tool === "schedule_followup") return "a durable wake"; - if (tool === "schedule_workflow") return "a recurring workflow"; - if (tool === "install_skill") return clean || "a catalog skill"; - if (tool === "search_web") return clean || "the public web"; - if (tool === "inspect_web_source") return clean || "a public HTTPS source"; - if (tool === "attach_web_image") return clean || "a sourced web image"; - return clean.length && clean.length <= 96 ? clean : tool.replaceAll("_", " "); -} - -function actionVerb(tool: string): string { - const verbs: Record = { - run_command: "Ran work in", - create_channel: "Created", - remember: "Recorded", - attach_file: "Attached", - call_skipper: "Called Skipper across", - call_agent: "Handed work back to", - invite_agent: "Invited", - request_skill: "Requested", - propose_skill: "Crystallized", - create_skill: "Created", - search_skill_catalog: "Searched", - inspect_skill: "Inspected", - search_web: "Searched", - search_channel_history: "Searched", - read_channel_session: "Read", - inspect_web_source: "Inspected", - attach_web_image: "Attached", - install_skill: "Installed", - grant_gmail_access: "Granted", - gmail_list_accounts: "Listed", - gmail_search: "Searched", - gmail_get: "Read", - gmail_create_draft: "Created", - schedule_followup: "Scheduled", - schedule_workflow: "Scheduled", - list_workflows: "Listed", - set_workflow_status: "Updated", - generate_image: "Generated", - ask_user: "Opened", - }; - return verbs[tool] || "Used"; -} - -function actionSummary(tool: string, input: string, status: string, actor: string): string { - const outcome = status === "failed" ? "failed" : status === "running" ? "working" : "complete"; - return `${actionVerb(tool)} ${actionObject(tool, input, actor)} → ${outcome}.`; -} - function recordAction(agentId: number, threadId: number, channelId: number, tool: string, input: string, actor: string): number { if (!agentId) return 0; const id = run("INSERT INTO tool_actions (agent_id, thread_id, tool, input_summary, status, created) VALUES (?,?,?,?,'running',?)", agentId, threadId, tool, input.slice(0, 1000), now()).lastInsertRowid; @@ -1182,12 +1081,6 @@ async function runCommand(bot: Row, agent: RuntimeAgent | undefined, channelId: } } -export function toolActionStatus(result: string): "failed" | "running" | "complete" { - if (/^Error:/i.test(result) || /^status=failed(?:\n|$)/i.test(result)) return "failed"; - if (/^status=running(?:\n|$)/i.test(result)) return "running"; - return "complete"; -} - async function createNativeChannel(nameInput: string, purposeInput: string, userId: number): Promise { if (!userId) return "Error: a Captain could not be identified for this request."; const name = normalizeChannelName(nameInput); diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..de5c690 --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,102 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; + +export const JSON_BODY_LIMIT = 1024 * 1024; +export const UPLOAD_BODY_LIMIT = 25 * 1024 * 1024; + +export const MIME: Record = { + ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", + ".webmanifest": "application/manifest+json", ".svg": "image/svg+xml", ".png": "image/png", + ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", + ".txt": "text/plain", ".md": "text/markdown", ".csv": "text/csv", ".yaml": "application/yaml", + ".yml": "application/yaml", ".xml": "application/xml", ".pdf": "application/pdf", ".mp3": "audio/mpeg", + ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".mp4": "video/mp4", + ".webm": "video/webm", ".ico": "image/x-icon", ".woff2": "font/woff2", +}; + +// TLS is terminated by the deployment's reverse proxy. Local and first-run +// HTTP deployments must still load their relative JS and CSS assets. +export const SECURITY_HEADERS: Record = { + "content-security-policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: https:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'", + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "same-origin", + "permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)", +}; + +export function json(res: ServerResponse, code: number, responseBody: unknown): void { + res.writeHead(code, { "content-type": "application/json", ...SECURITY_HEADERS }); + res.end(JSON.stringify(responseBody)); +} + +const MOBILE_APP_ORIGINS = new Set(["capacitor://localhost", "https://localhost"]); + +export function applyMobileCors(req: IncomingMessage, res: ServerResponse): boolean { + const origin = String(req.headers.origin || ""); + if (!MOBILE_APP_ORIGINS.has(origin)) return false; + res.setHeader("access-control-allow-origin", origin); + res.setHeader("access-control-allow-methods", "GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS"); + res.setHeader("access-control-allow-headers", "Authorization, Content-Type, X-Filename"); + res.setHeader("access-control-expose-headers", "Content-Disposition, Content-Type"); + res.setHeader("vary", "Origin"); + return true; +} + +export function body(req: IncomingMessage, limit = JSON_BODY_LIMIT): Promise { + return new Promise((resolve, reject) => { + const declared = Number(req.headers["content-length"] || 0); + if (declared > limit) { + const error = new Error(`Request exceeds the ${Math.floor(limit / 1024 / 1024)} MB limit.`); + error.name = "PayloadTooLargeError"; + reject(error); + return; + } + const chunks: Buffer[] = []; + let received = 0; + let oversized = false; + req.on("data", (chunk: Buffer) => { + received += chunk.length; + if (received > limit) { oversized = true; chunks.length = 0; } + else if (!oversized) chunks.push(chunk); + }); + req.on("end", () => { + if (oversized) { + const error = new Error(`Request exceeds the ${Math.floor(limit / 1024 / 1024)} MB limit.`); + error.name = "PayloadTooLargeError"; + reject(error); + } else resolve(Buffer.concat(chunks)); + }); + req.on("error", reject); + }); +} + +export async function jbody(req: IncomingMessage): Promise> { + const raw = await body(req); + try { return JSON.parse(raw.toString() || "{}"); } + catch { return {}; } +} + +export function requestAddress(req: IncomingMessage): string { + const forwarded = String(req.headers["cf-connecting-ip"] || "").trim(); + return /^[a-f0-9:.]{3,64}$/i.test(forwarded) ? forwarded : String(req.socket.remoteAddress || "unknown"); +} + +const requestLimits = new Map(); + +export function rateLimited(key: string, limit: number, windowMs: number): boolean { + if (requestLimits.size >= 5000 && !requestLimits.has(key)) { + const time = Date.now(); + for (const [candidate, value] of requestLimits) if (value.reset <= time) requestLimits.delete(candidate); + while (requestLimits.size >= 5000) requestLimits.delete(requestLimits.keys().next().value as string); + } + const current = requestLimits.get(key); + if (!current || current.reset <= Date.now()) { + requestLimits.set(key, { count: 1, reset: Date.now() + windowMs }); + return false; + } + current.count++; + return current.count > limit; +} + +export function clearRateLimit(key: string): void { + requestLimits.delete(key); +} diff --git a/src/server/index.ts b/src/server/index.ts index 25664ab..e1d523c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -6,6 +6,7 @@ import { join, extname } from "node:path"; import { randomBytes } from "node:crypto"; import { platform } from "node:os"; import { WebSocketServer, type WebSocket } from "ws"; +import { applyMobileCors, body, clearRateLimit, jbody, json, MIME, rateLimited, requestAddress, SECURITY_HEADERS, UPLOAD_BODY_LIMIT } from "./http.ts"; import { db, isMainChannel, normalizeWorkspaceName, q, q1, run, now, hashPassword, verifyPassword, newToken, seed, DATA_DIR, UPLOAD_DIR, type Row } from "./db.ts"; import { createMessage, deleteMessage, serializeMessage, setModelPref, setModelPolicy, resolvedModelPolicy, botView, providerView, botEndpoint, botsInChannel, botIsInChannel, addBotToChannel, findMentionedBots } from "./store.ts"; import { computerRowView, fetchModels } from "./computer.ts"; @@ -119,110 +120,12 @@ const PORT = Number(process.env.PORT || 8123); const HOST = process.env.HELM_HOST || "0.0.0.0"; const APP_ROOT = process.env.HELM_APP_ROOT || process.cwd(); const PUBLIC = join(APP_ROOT, "public"); -const JSON_BODY_LIMIT = 1024 * 1024; -const UPLOAD_BODY_LIMIT = 25 * 1024 * 1024; const WORKSPACE_PHOTO = join(DATA_DIR, "workspace-photo"); const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60_000; const INTERNAL_WAKE_TOKEN = String(process.env.HELM_INTERNAL_WAKE_TOKEN || ""); const MOBILE_API_VERSION = 1; -const MOBILE_APP_ORIGINS = new Set(["capacitor://localhost", "https://localhost"]); -const MIME: Record = { - ".html": "text/html", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json", - ".webmanifest": "application/manifest+json", - ".svg": "image/svg+xml", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".txt": "text/plain", - ".md": "text/markdown", - ".csv": "text/csv", - ".yaml": "application/yaml", - ".yml": "application/yaml", - ".xml": "application/xml", - ".pdf": "application/pdf", - ".mp3": "audio/mpeg", - ".wav": "audio/wav", - ".ogg": "audio/ogg", - ".m4a": "audio/mp4", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".ico": "image/x-icon", - ".woff2": "font/woff2", -}; - seed(); -// ---- helpers ---- -// TLS is terminated by the deployment's reverse proxy. Do not advertise an -// HTTPS-only policy here: local and first-run HTTP deployments must still load -// their relative JS and CSS assets. -const SECURITY_HEADERS: Record = { - "content-security-policy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: https:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'", - "x-content-type-options": "nosniff", - "x-frame-options": "DENY", - "referrer-policy": "same-origin", - // Speech-to-text is an explicit, user-triggered first-party composer action. - // Keep camera and location denied, but allow this origin to request the mic. - "permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)", -}; -const json = (res: ServerResponse, code: number, body: unknown): void => { - const s = JSON.stringify(body); - res.writeHead(code, { "content-type": "application/json", ...SECURITY_HEADERS }); - res.end(s); -}; -const applyMobileCors = (req: IncomingMessage, res: ServerResponse): boolean => { - const origin = String(req.headers.origin || ""); - if (!MOBILE_APP_ORIGINS.has(origin)) return false; - res.setHeader("access-control-allow-origin", origin); - res.setHeader("access-control-allow-methods", "GET, HEAD, POST, PATCH, PUT, DELETE, OPTIONS"); - res.setHeader("access-control-allow-headers", "Authorization, Content-Type, X-Filename"); - res.setHeader("access-control-expose-headers", "Content-Disposition, Content-Type"); - res.setHeader("vary", "Origin"); - return true; -}; -const body = (req: IncomingMessage, limit = JSON_BODY_LIMIT): Promise => new Promise((resolve, reject) => { - const declared = Number(req.headers["content-length"] || 0); - if (declared > limit) { const error = new Error(`Request exceeds the ${Math.floor(limit / 1024 / 1024)} MB limit.`); error.name = "PayloadTooLargeError"; reject(error); return; } - const chunks: Buffer[] = []; - let received = 0; - let oversized = false; - req.on("data", (chunk: Buffer) => { - received += chunk.length; - if (received > limit) { oversized = true; chunks.length = 0; } - else if (!oversized) chunks.push(chunk); - }); - req.on("end", () => { - if (oversized) { const error = new Error(`Request exceeds the ${Math.floor(limit / 1024 / 1024)} MB limit.`); error.name = "PayloadTooLargeError"; reject(error); } - else resolve(Buffer.concat(chunks)); - }); - req.on("error", reject); -}); -const jbody = async (req: IncomingMessage): Promise> => { const raw = await body(req); try { return JSON.parse(raw.toString() || "{}"); } catch { return {}; } }; -const requestAddress = (req: IncomingMessage): string => { - const forwarded = String(req.headers["cf-connecting-ip"] || "").trim(); - return /^[a-f0-9:.]{3,64}$/i.test(forwarded) ? forwarded : String(req.socket.remoteAddress || "unknown"); -}; -const requestLimits = new Map(); -const rateLimited = (key: string, limit: number, windowMs: number): boolean => { - if (requestLimits.size >= 5000 && !requestLimits.has(key)) { - const time = now(); - for (const [candidate, value] of requestLimits) if (value.reset <= time) requestLimits.delete(candidate); - while (requestLimits.size >= 5000) requestLimits.delete(requestLimits.keys().next().value as string); - } - const current = requestLimits.get(key); - if (!current || current.reset <= now()) { - requestLimits.set(key, { count: 1, reset: now() + windowMs }); - return false; - } - current.count++; - return current.count > limit; -}; - const userFromToken = (token: string | null): Row | undefined => { if (!token) return undefined; const s = q1("SELECT user_id, created FROM sessions WHERE token=?", token); @@ -662,7 +565,7 @@ const server = createServer(async (req, res) => { if (rateLimited(loginKey, 12, 15 * 60_000)) return json(res, 429, { error: "Too many sign-in attempts. Try again later." }); const u = q1("SELECT * FROM users WHERE username=?", username); if (!u || !verifyPassword(String(b.password || ""), String(u.pass))) return json(res, 401, { error: "Wrong username or password." }); - requestLimits.delete(loginKey); + clearRateLimit(loginKey); const token = newToken(); run("INSERT INTO sessions (token, user_id, created) VALUES (?,?,?)", token, u.id, now()); return json(res, 200, { token, user: publicUser(u) }); diff --git a/test/phase6-modules.mjs b/test/phase6-modules.mjs new file mode 100644 index 0000000..6a16cff --- /dev/null +++ b/test/phase6-modules.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { PassThrough } from "node:stream"; +import test from "node:test"; + +import { actionSummary, completedToolAnswer, toolActionStatus } from "../src/server/bot-output.ts"; +import { body, clearRateLimit, jbody, json, rateLimited, SECURITY_HEADERS } from "../src/server/http.ts"; +import { + formatRoughTokens, + formatThreadFollowupCountdown, + parseToolBody, + progressCounts, + progressPreviewLine, + stickyWorkingLabel, + threadUsageLabel, + workingChipLabel, + workingDisplayBody, +} from "../src/client/thread-formatters.ts"; + +function request(payload, headers = {}) { + const stream = new PassThrough(); + stream.headers = headers; + stream.socket = { remoteAddress: "127.0.0.1" }; + queueMicrotask(() => stream.end(payload)); + return stream; +} + +test("HTTP extraction preserves JSON, security headers, malformed input, and fail-closed limits", async () => { + const writes = []; + const response = { + writeHead(code, headers) { writes.push({ code, headers }); }, + end(value) { writes.push(value); }, + }; + json(response, 409, { error: "exact conflict" }); + assert.equal(writes[0].code, 409); + assert.equal(writes[0].headers["content-type"], "application/json"); + assert.equal(writes[0].headers["x-frame-options"], "DENY"); + assert.equal(writes[0].headers["permissions-policy"], SECURITY_HEADERS["permissions-policy"]); + assert.equal(writes[1], '{"error":"exact conflict"}'); + + assert.deepEqual(await jbody(request('{"ok":true}')), { ok: true }); + assert.deepEqual(await jbody(request("{invalid")), {}, "malformed JSON remains an empty request object"); + await assert.rejects(body(request("abcd", { "content-length": "4" }), 3), (error) => { + assert.equal(error.name, "PayloadTooLargeError"); + assert.equal(error.message, "Request exceeds the 0 MB limit."); + return true; + }); + await assert.rejects(body(request("abcd"), 3), { name: "PayloadTooLargeError" }, "streamed bodies fail closed too"); + + clearRateLimit("phase6"); + assert.equal(rateLimited("phase6", 2, 60_000), false); + assert.equal(rateLimited("phase6", 2, 60_000), false); + assert.equal(rateLimited("phase6", 2, 60_000), true); + clearRateLimit("phase6"); + assert.equal(rateLimited("phase6", 2, 60_000), false, "successful login reset still clears the exact key"); +}); + +test("bot output extraction preserves exact completion and audit wording", () => { + assert.equal(toolActionStatus("status=completed\nexit_code=0\nok"), "complete"); + assert.equal(toolActionStatus("status=failed\nexit_code=100\napt failed"), "failed"); + assert.equal(toolActionStatus("Error: runtime unavailable"), "failed"); + assert.equal(toolActionStatus("status=running\nexit_code=null"), "running"); + assert.equal(completedToolAnswer("run_command", "status=completed\nexit_code=0\nok"), + "The command completed.\n\n```text\nstatus=completed\nexit_code=0\nok\n```"); + assert.equal(completedToolAnswer("gmail_create_draft", '{"account":"captain@example.test","draft_id":"d1"}'), + "Created a Gmail draft in **captain@example.test** (draft d1). It was not sent."); + assert.equal(completedToolAnswer("gmail_search", "not json"), + "Gmail search completed, but the model did not produce a final explanation. The result remains available in this session."); + assert.equal(actionSummary("run_command", "printf ok", "complete", "resident"), "Ran work in the resident workspace → complete."); + assert.equal(actionSummary("install_skill", "bounded-research", "failed", "skipper"), "Installed bounded-research → failed."); +}); + +test("thread formatter extraction preserves progress, usage, and countdown edge cases", () => { + assert.deepEqual(parseToolBody("search_web: latest news\n3 results"), { title: "search web", input: "latest news", output: "3 results" }); + assert.deepEqual(parseToolBody("\nresult"), { title: "tool", input: "", output: "result" }); + const progress = [ + { id: 1, kind: "thinking", body: "Inspecting the source", status: "complete" }, + { id: 2, kind: "tool", body: "search_web: current evidence", status: "running" }, + ]; + assert.equal(progressPreviewLine(progress), "search web · current evidence"); + assert.equal(stickyWorkingLabel(progress), "Inspecting the source"); + assert.equal(progressCounts(progress), "1 tool · 1 thought"); + assert.equal(workingDisplayBody({ body: "_Working…_", progress }), "Inspecting the source"); + assert.equal(workingChipLabel({ body: "_Working…_", progress }), "Inspecting the source"); + assert.equal(workingChipLabel({ body: "A".repeat(80) }), `${"A".repeat(72)}…`); + assert.equal(formatRoughTokens(-4), "0"); + assert.equal(formatRoughTokens(1_240), "1.2k"); + assert.equal(formatRoughTokens(10_200), "10k"); + assert.equal(formatRoughTokens(1_500_000), "1.5M"); + assert.equal(threadUsageLabel({ input_tokens: 1_240, output_tokens: 340 }), "Used 1.2k in · 340 out"); + const now = 1_000_000; + assert.equal(formatThreadFollowupCountdown(now - 1, now), "now"); + assert.equal(formatThreadFollowupCountdown(now + 9_000, now), "9s"); + assert.equal(formatThreadFollowupCountdown(now + 65_000, now), "1m 05s"); + assert.equal(formatThreadFollowupCountdown(now + 3_665_000, now), "1h 01m 05s"); +}); + +test("module architecture report is advisory and recognizes the ratcheted baseline", () => { + const output = execFileSync(process.execPath, ["scripts/module-architecture-report.mjs"], { encoding: "utf8" }); + assert.match(output, /advisory only/); + assert.match(output, /Large modules[\s\S]*High fan-in[\s\S]*High fan-out[\s\S]*Import cycles/); + assert.match(output, /No new or regressed budget flags\./); + assert.match(output, /Ratchet a legacy budget down after an extraction/); + assert.doesNotMatch(output, /photon-sidecar\.bundle/, "generated build artifacts never enter the source-module budget"); +}); diff --git a/test/workspace-interactions.mjs b/test/workspace-interactions.mjs index 5c74c70..c54f9b8 100644 --- a/test/workspace-interactions.mjs +++ b/test/workspace-interactions.mjs @@ -9,6 +9,7 @@ const settings = await readFile(resolve(root, "src/client/settings.ts"), "utf8") const routing = await readFile(resolve(root, "src/client/routing.ts"), "utf8"); const desktop = await readFile(resolve(root, "desktop/main.cjs"), "utf8"); const server = await readFile(resolve(root, "src/server/index.ts"), "utf8"); +const http = await readFile(resolve(root, "src/server/http.ts"), "utf8"); const serviceWorker = await readFile(resolve(root, "public/sw.js"), "utf8"); test("workspace sidebar interactions have durable, member-scoped contracts", () => { @@ -26,7 +27,7 @@ test("workspace sidebar interactions have durable, member-scoped contracts", () }); test("speech-to-text is explicit, graceful, and combination-safe", () => { - assert.ok(server.includes('"permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)"'), "the web control plane permits first-party microphone access for explicit dictation"); + assert.ok(http.includes('"permissions-policy": "camera=(), microphone=(self), geolocation=(), unload=(self)"'), "the web control plane permits first-party microphone access for explicit dictation"); assert.match(app, /SpeechRecognition\?[^\n]+webkitSpeechRecognition/, "standard and prefixed browser recognition are supported"); assert.match(app, /dataset: \{ speechToggle: "" \}/, "the composer exposes an explicit mic control"); assert.match(app, /export function mountSpeechToTextControl/, "other text surfaces reuse the same dictation control and shortcut behavior");