Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions config/module-budgets.json
Original file line number Diff line number Diff line change
@@ -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"]
]
}
}
40 changes: 40 additions & 0 deletions docs/module-map.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions scripts/module-architecture-report.mjs
Original file line number Diff line number Diff line change
@@ -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");
1 change: 1 addition & 0 deletions scripts/run-test-suite.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 14 additions & 114 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
Expand Down
Loading
Loading