logancyang_obsidian-copilot/src/agentMode/session/AgentChatPersistenceManager.ts
Zero Liu 29254f6f16
feat(agent-mode): per-session token/context meter in the composer (#2654)
* feat(agent-mode): capture per-session token usage (context meter data)

Normalize what each coding-agent backend already reports into one
backend-agnostic SessionUsage, land it on AgentSession, expose it via
getSessionUsage(), and persist the latest snapshot in chat frontmatter so a
resumed session shows it immediately. No UI yet (Phase 2).

- session/types: SessionUsage + usage_update SessionUpdate variant
- sdk translator: emit usage from the Claude SDK result (used = input +
  cache_read + cache_creation + output; window = max modelUsage.contextWindow)
- acp/wireTranslate: map ACP usage_update (size/used/cost) to the domain
- acp/AcpBackendProcess: used-only prompt-result fallback, gated so it never
  clobbers a live occupancy figure (ACP totalTokens is cumulative, not in-context)
- AgentSession: store/notify usage, precedence carries a known window forward,
  seed on load; AgentSessionManager threads persistence load/save
- AgentChatPersistenceManager: round-trip usage as frontmatter JSON

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): context-window meter in the composer

A circular meter beside the composer's context glyph, filled to the % of the
agent session's context window used; accent normally, warning color past 85%.
Click opens a popover with used/total tokens, an input·output·cache breakdown,
and estimated session cost. Falls back to the legacy count-only chip when a
backend reports usage but no window, and renders nothing before the first turn.

- useSessionUsage: subscribe to the backend's live SessionUsage
- AgentContextMeter: SVG ring + popover + TokenCounter fallback; owns its
  leading separator so it hides cleanly; guards non-finite values
- thread an optional usageIndicator slot through ChatInput → ContextControl →
  ChatContextMenu (pure pass-through; legacy simple chat unaffected)
- AgentHome mounts the meter for all agent sessions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): move context meter into the New Chat / History control row

Relocate the meter from the composer's context bar into AgentChatControls,
left of the New Chat button — the agent-mode analog of where the legacy
TokenCounter sat, matching the original intent. The ring trigger is now a
plain icon button (no inline % text; the percentage stays in the popover).

As a result the meter no longer needs the shared composer plumbing: the
optional usageIndicator slot threaded through ChatInput → ContextControl →
ChatContextMenu is fully removed, so those shared components are byte-identical
to before this feature and the legacy simple chat is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): horizontal context-window bar in the meter popover

Revamp the popover to a cleaner, less technical layout: a "Context window"
label with `used / total (percent)` and the estimated cost on the same line
(cost at the right edge), above a horizontal progress bar (reusing the shared
Progress primitive). Tokens now format with one decimal + k/M (e.g.
248.0k / 1.0M). Drops the input/output/cache breakdown. The compact ring stays
as the control-row trigger; the warning color still shows on the ring and the
percentage past 85%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style(agent-mode): keep the context-window label on one line

Use the smaller ui font token for the popover header and widen it (w-72 → w-80)
with a non-wrapping label, so "Context window" no longer breaks onto two lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): open the context meter popover on hover

Drive the popover's open state from hover/focus instead of click, with a short
close delay and content-hover retention so moving onto the card doesn't dismiss
it. Auto-focus is suppressed so opening on hover never steals focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(agent-mode): drive context meter from occupancy, not cumulative usage

The Claude SDK result message's `usage` sums every API call in a turn (tool
loops re-read the whole context from cache each iteration), so dividing it
by the window pegged the meter after tool-heavy turns even when the live
context still fit. Source occupancy from the last top-level assistant
message's own per-call usage instead, and pair it with that model's window —
prefix-matching the bare model id ("claude-opus-4-8") against the suffixed
`modelUsage` key ("claude-opus-4-8[1m]"), never `Math.max` across models.

Also: a windowless snapshot (ACP's cumulative prompt-result fallback) no
longer borrows a prior window to render a bogus percentage ring; it stays
count-only until a live occupancy update supersedes it.

Verified against runtime frame logs: a num_turns:10 Claude result summed
~646k tokens while real per-call occupancy was ~108k; opencode/codex report
occupancy directly via usage_update (this path was already correct).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(agent-mode): use Tooltip for the context ring, drop manual popover timer

The ring meter opened its stats card via a Popover with a hand-rolled hover
state and a setTimeout close delay (to bridge trigger→content). Replace it
with the shared Radix Tooltip, which handles hover/focus open-close and
hoverable content natively — removing the open state, close timer, and the
onMouse/onFocus/onBlur handlers. Relies on the TooltipProvider already at the
chat-view root, alongside the sibling control buttons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agent-mode): drop session cost from the context meter, show usage only

Remove `costUsd` from `SessionUsage` and everywhere that fed it — the Claude
SDK `total_cost_usd` mapping, the ACP `usage_update` `cost.amount` mapping, and
the meter's cost label + `formatUsd`. The meter now focuses solely on context
occupancy (used / window and the % ring). This also moots the ACP cost-currency
concern (raised in review), since no cost is surfaced at all.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:36:11 -07:00

525 lines
20 KiB
TypeScript

import { serializeFanoutComposite } from "@/agentMode/session/fanout/fanoutTypes";
import { AGENT_CHAT_MODE, AI_SENDER, USER_SENDER } from "@/constants";
import { logError, logInfo, logWarn } from "@/logger";
import { getSettings } from "@/settings/model";
import { FormattedDateTime } from "@/types/message";
import {
ensureFolderExists,
formatDateTime,
getUtf8ByteLength,
truncateToByteLimit,
} from "@/utils";
import {
isFileAlreadyExistsError,
isInVaultCache,
isNameTooLongError,
listMarkdownFiles,
patchFrontmatter,
readFrontmatterViaAdapter,
trashFile,
} from "@/utils/vaultAdapterUtils";
import { TFile, type App } from "obsidian";
import { Notice } from "obsidian";
import { coerceProjectId, escapeYamlString, unescapeYamlString } from "./agentChatYaml";
import { GLOBAL_SCOPE } from "./scope";
import type { AgentChatMessage, BackendId, SessionUsage } from "./types";
const SAFE_FILENAME_BYTE_LIMIT = 100;
export const AGENT_FILENAME_PREFIX = "agent__";
/**
* Parse the frontmatter `usage` field (a JSON string) back into a
* {@link SessionUsage}. Returns `undefined` for absent, non-string, malformed,
* or wrong-shaped values so a corrupt frontmatter never rejects the whole load.
*/
function parseUsageJson(raw: unknown): SessionUsage | undefined {
if (typeof raw !== "string" || raw.trim().length === 0) return undefined;
try {
const parsed = JSON.parse(raw) as unknown;
if (
typeof parsed === "object" &&
parsed !== null &&
typeof (parsed as SessionUsage).usedTokens === "number" &&
typeof (parsed as SessionUsage).updatedAt === "number"
) {
return parsed as SessionUsage;
}
} catch {
// Malformed JSON — fall through to undefined.
}
return undefined;
}
/**
* Result of `loadFile` — restores display-only Agent Mode messages plus
* routing info needed to spawn the right backend session.
*/
export interface LoadedAgentChat {
messages: AgentChatMessage[];
backendId: BackendId;
topic?: string;
label?: string;
/**
* Backend-side session id captured at save time. When present, the loader
* can ask the backend to resume/load that session so the agent regains
* conversation context on the next turn. Absent for chats saved before
* resume was wired up — those fall back to a fresh session.
*/
sessionId?: string;
/**
* Scope this chat belongs to: a real project id, or {@link GLOBAL_SCOPE}.
* HARD CONTRACT: a chat with no `projectId` frontmatter (every legacy
* `agent__` chat) resolves to `GLOBAL_SCOPE`, so it keeps appearing in the
* global history. Never inferred from the filename — frontmatter is the only
* authority.
*/
projectId: string;
/**
* Latest token-usage snapshot captured at save time, or `undefined` for chats
* saved before usage was wired up (or with malformed usage frontmatter). Lets
* a resumed session show its last-known usage before the next turn.
*/
usage?: SessionUsage;
}
interface ExistingMeta {
topic?: string;
label?: string;
lastAccessedAt?: number;
sessionId?: string;
projectId?: string;
usage?: SessionUsage;
}
/**
* Backend-agnostic on-disk persistence for Agent Mode sessions. Mirrors the
* legacy `ChatPersistenceManager` shape so a single `ChatHistoryPopover` can
* render both lists, but with three differences:
*
* 1. Files are prefixed with `agent__` so they never collide with legacy
* project-prefixed (`{projectId}__`) or unprefixed chats.
* 2. Frontmatter records `mode: agent` and `backendId: <id>` so the loader
* can route a history click to the right backend.
* 3. No project / AI-topic generation — Agent Mode has no project concept
* and no chain manager to generate titles with.
*
* Sessions with zero visible messages are never written.
*/
export class AgentChatPersistenceManager {
constructor(private readonly app: App) {}
/**
* Save the supplied messages to disk. Returns the resulting file (or the
* existing path on hidden-directory writes), or `null` when the session has
* nothing user-visible to persist.
*
* `existingPath` lets the caller pin updates to a previously-saved file so
* they're applied even if the messages list shrinks below the original
* `firstMessageEpoch`. When omitted, the file is matched by epoch.
*/
async saveSession(
messages: AgentChatMessage[],
backendId: BackendId,
options?: {
label?: string | null;
modelKey?: string;
existingPath?: string;
sessionId?: string | null;
/**
* Scope to record in frontmatter. Pass a real project id to bind the
* chat to that project; omit (or pass `GLOBAL_SCOPE`) for a global chat,
* which writes no `projectId` so it stays indistinguishable from legacy
* global chats.
*/
projectId?: string;
/** Latest token-usage snapshot to persist for resume. */
usage?: SessionUsage;
}
): Promise<{ path: string } | null> {
if (messages.length === 0) return null;
try {
const settings = getSettings();
const chatContent = this.formatChatContent(messages);
const firstMessageEpoch = messages[0].timestamp?.epoch ?? Date.now();
await ensureFolderExists(this.app.vault, settings.defaultSaveFolder);
const existingFile = options?.existingPath
? this.resolveExistingFile(options.existingPath)
: null;
const existingMeta = existingFile ? await this.readExistingMeta(existingFile) : {};
const preferredFileName = existingFile
? existingFile.path
: this.generateFileName(messages, firstMessageEpoch, existingMeta.topic);
const noteContent = this.generateNoteContent({
chatContent,
firstMessageEpoch,
backendId,
topic: existingMeta.topic,
label: options?.label ?? existingMeta.label,
modelKey: options?.modelKey,
lastAccessedAt: existingMeta.lastAccessedAt,
sessionId: options?.sessionId ?? existingMeta.sessionId,
// Reason: round-trip an existing chat's scope when the caller doesn't
// re-supply it (e.g. autosave updates), so a project chat never silently
// demotes itself to global on a later save. Coerce first so a blank
// option falls through to the existing scope instead of clobbering it.
projectId: coerceProjectId(options?.projectId) ?? existingMeta.projectId,
// Round-trip the persisted usage when the caller doesn't re-supply it,
// so a save that isn't triggered by a usage change keeps the snapshot.
usage: options?.usage ?? existingMeta.usage,
});
if (existingFile && isInVaultCache(this.app, existingFile.path)) {
await this.app.vault.modify(existingFile, noteContent);
return { path: existingFile.path };
}
if (
!isInVaultCache(this.app, preferredFileName) &&
(await this.app.vault.adapter.exists(preferredFileName))
) {
await this.app.vault.adapter.write(preferredFileName, noteContent);
return { path: preferredFileName };
}
try {
const created = await this.app.vault.create(preferredFileName, noteContent);
return { path: created.path };
} catch (err) {
if (isFileAlreadyExistsError(err)) {
await this.app.vault.adapter.write(preferredFileName, noteContent);
return { path: preferredFileName };
}
if (isNameTooLongError(err)) {
logWarn("[AgentChatPersistenceManager] Filename too long, falling back to minimal name");
const fallback = `${settings.defaultSaveFolder}/${AGENT_FILENAME_PREFIX}chat-${firstMessageEpoch}.md`;
try {
const created = await this.app.vault.create(fallback, noteContent);
return { path: created.path };
} catch (fallbackErr) {
if (isFileAlreadyExistsError(fallbackErr)) {
await this.app.vault.adapter.write(fallback, noteContent);
return { path: fallback };
}
throw fallbackErr;
}
}
throw err;
}
} catch (error) {
logError("[AgentChatPersistenceManager] Error saving session:", error);
return null;
}
}
/**
* Parse a saved agent chat file back into `AgentChatMessage`s and routing
* info. Tool/plan/thought parts are not restored — the markdown format only
* preserves sender + text (display-only history, mirroring legacy mode).
*/
async loadFile(file: TFile): Promise<LoadedAgentChat> {
let content: string;
try {
content = await this.app.vault.read(file);
} catch {
content = await this.app.vault.adapter.read(file.path);
}
const { frontmatter, body } = this.splitFrontmatter(content);
const backendId = (frontmatter.backendId ?? "").trim();
if (!backendId) {
throw new Error(`Missing backendId in agent chat frontmatter: ${file.path}`);
}
const topic = frontmatter.topic?.trim() || undefined;
const label = frontmatter.agentLabel?.trim() || undefined;
const sessionId = frontmatter.sessionId?.trim() || undefined;
// HARD CONTRACT: absent/blank projectId → GLOBAL_SCOPE, so legacy `agent__`
// chats stay in the global history. Never inferred from the filename.
const projectId = frontmatter.projectId?.trim() || GLOBAL_SCOPE;
const usage = parseUsageJson(frontmatter.usage);
const messages = this.parseChatBody(body);
logInfo(
`[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId}, sessionId=${sessionId ?? "none"}, projectId=${projectId})`
);
return { messages, backendId, topic, label, sessionId, projectId, usage };
}
/**
* List every persisted Agent Mode chat file (across all backends). Filters
* by the `agent__` filename prefix so the result is backend-agnostic and
* never collides with legacy or project chats.
*/
async getAgentChatHistoryFiles(): Promise<TFile[]> {
const settings = getSettings();
const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder);
return files.filter((file) => file.basename.startsWith(AGENT_FILENAME_PREFIX));
}
/** Update the user-visible topic in frontmatter. */
async updateTopic(fileId: string, newTopic: string): Promise<void> {
await patchFrontmatter(this.app, fileId, { topic: newTopic.trim() });
}
async deleteFile(fileId: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file) {
await trashFile(this.app, file);
new Notice("Chat moved to trash.");
return;
}
if (await this.app.vault.adapter.exists(fileId)) {
await this.app.vault.adapter.remove(fileId);
new Notice("Chat deleted.");
return;
}
throw new Error("Chat file not found.");
}
private resolveExistingFile(path: string): TFile | null {
const file = this.app.vault.getAbstractFileByPath(path);
return file instanceof TFile ? file : null;
}
private async readExistingMeta(file: TFile): Promise<ExistingMeta> {
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (cached) {
return {
topic: cached.topic,
label: cached.agentLabel,
lastAccessedAt:
typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined,
sessionId: typeof cached.sessionId === "string" ? cached.sessionId : undefined,
projectId: coerceProjectId(cached.projectId),
usage: parseUsageJson(cached.usage),
};
}
try {
const fm = await readFrontmatterViaAdapter(this.app, file.path);
if (!fm) return {};
const lastAccessed = fm.lastAccessedAt ? Number(fm.lastAccessedAt) : undefined;
return {
topic: fm.topic,
label: fm.agentLabel,
lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined,
sessionId: typeof fm.sessionId === "string" ? fm.sessionId : undefined,
projectId: coerceProjectId(fm.projectId),
usage: parseUsageJson(fm.usage),
};
} catch {
return {};
}
}
private formatChatContent(messages: AgentChatMessage[]): string {
return messages
.map((m) => {
const ts = m.timestamp ? m.timestamp.display : "Unknown time";
// A fan-out turn streams into `m.fanout`; its composite body is written
// to `m.message` only at completion. If autosave fires mid-turn (a long
// turn outliving the debounce, then reload/close/crash), serialize the
// LIVE fanout so the saved chat keeps the streamed per-agent text instead
// of a blank assistant bubble. Backend ids label the sections here; the
// completed turn later overwrites `m.message` with display-name labels.
const body =
m.message.length === 0 && m.fanout
? serializeFanoutComposite(m.fanout, (id) => id)
: m.message;
return `**${m.sender}**: ${body}\n[Timestamp: ${ts}]`;
})
.join("\n\n");
}
private parseChatBody(body: string): AgentChatMessage[] {
const messages: AgentChatMessage[] = [];
const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(body)) !== null) {
const sender = match[1] === "user" ? USER_SENDER : AI_SENDER;
const fullContent = match[2].trim();
const lines = fullContent.split("\n");
let endIndex = lines.length;
let timestampStr = "Unknown time";
if (lines[endIndex - 1]?.startsWith("[Timestamp: ")) {
const tsMatch = lines[endIndex - 1].match(/\[Timestamp: (.*?)\]/);
if (tsMatch) {
timestampStr = tsMatch[1];
endIndex--;
}
}
const messageText = lines.slice(0, endIndex).join("\n").trim();
let timestamp: FormattedDateTime | null = null;
if (timestampStr !== "Unknown time") {
const date = new Date(timestampStr);
if (!isNaN(date.getTime())) {
timestamp = {
epoch: date.getTime(),
display: timestampStr,
fileName: "",
};
}
}
// Deterministic id: stable across reloads so React keeps message
// identity when the UI re-renders the same loaded chat. Uses the
// message's own epoch when present, falling back to the index.
const id = timestamp
? `loaded-${messages.length}-${timestamp.epoch}`
: `loaded-${messages.length}`;
messages.push({
id,
message: messageText,
sender,
isVisible: true,
timestamp,
});
}
return messages;
}
private splitFrontmatter(content: string): {
frontmatter: Record<string, string>;
body: string;
} {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return { frontmatter: {}, body: content };
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^(\w+):\s*(.+)/);
if (!m) continue;
const raw = m[2].trim();
// Unquote and unescape: only double-quoted values were escaped on save,
// so single-quoted / unquoted values are returned verbatim.
let value: string;
if (raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2) {
value = unescapeYamlString(raw.slice(1, -1));
} else if (raw.startsWith("'") && raw.endsWith("'") && raw.length >= 2) {
value = raw.slice(1, -1);
} else {
value = raw;
}
frontmatter[m[1]] = value;
}
return { frontmatter, body: content.slice(match[0].length).trim() };
}
private generateFileName(
messages: AgentChatMessage[],
firstMessageEpoch: number,
topic?: string
): string {
const settings = getSettings();
const formatted = formatDateTime(new Date(firstMessageEpoch));
const timestampFileName = formatted.fileName;
let topicForFilename: string;
if (topic) {
topicForFilename = topic;
} else {
const firstUser = messages.find((m) => m.sender === USER_SENDER);
topicForFilename = firstUser
? firstUser.message
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[{}[\]]/g, "")
.split(/\s+/)
.slice(0, 10)
.join(" ")
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "")
.trim() || "Untitled Agent Chat"
: "Untitled Agent Chat";
}
let customFileName = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}";
const filePrefix = AGENT_FILENAME_PREFIX;
const extensionBytes = getUtf8ByteLength(".md");
const filePrefixBytes = getUtf8ByteLength(filePrefix);
const formatOverhead = customFileName
.replace("{$topic}", "")
.replace("{$date}", timestampFileName.split("_")[0])
.replace("{$time}", timestampFileName.split("_")[1]);
const formatOverheadBytes = getUtf8ByteLength(formatOverhead);
const topicByteBudget = Math.max(
20,
SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - formatOverheadBytes
);
const topicWithUnderscores = topicForFilename.replace(/\s+/g, "_");
const truncatedTopic = truncateToByteLimit(topicWithUnderscores, topicByteBudget);
customFileName = customFileName
.replace("{$topic}", truncatedTopic)
.replace("{$date}", timestampFileName.split("_")[0])
.replace("{$time}", timestampFileName.split("_")[1]);
const sanitizedFileName = customFileName
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[{}[\]]/g, "_")
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "_");
const baseNameWithPrefix = `${filePrefix}${sanitizedFileName}.md`;
if (getUtf8ByteLength(baseNameWithPrefix) > SAFE_FILENAME_BYTE_LIMIT) {
const availableForBasename = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes;
const truncatedBasename = truncateToByteLimit(sanitizedFileName, availableForBasename);
return `${settings.defaultSaveFolder}/${filePrefix}${truncatedBasename}.md`;
}
return `${settings.defaultSaveFolder}/${baseNameWithPrefix}`;
}
private generateNoteContent(args: {
chatContent: string;
firstMessageEpoch: number;
backendId: BackendId;
topic?: string;
label?: string | null;
modelKey?: string;
lastAccessedAt?: number;
sessionId?: string | null;
projectId?: string;
usage?: SessionUsage;
}): string {
const settings = getSettings();
const lines: string[] = [
"---",
`epoch: ${args.firstMessageEpoch}`,
`mode: ${AGENT_CHAT_MODE}`,
`backendId: ${args.backendId}`,
];
// Reason: global chats write no projectId, staying byte-identical to legacy
// `agent__` chats (which loadFile maps back to GLOBAL_SCOPE). Coerce so a
// blank/whitespace id never leaks a stray frontmatter line.
const projectId = coerceProjectId(args.projectId);
if (projectId && projectId !== GLOBAL_SCOPE) {
lines.push(`projectId: "${escapeYamlString(projectId)}"`);
}
if (args.sessionId) lines.push(`sessionId: "${escapeYamlString(args.sessionId)}"`);
if (args.topic) lines.push(`topic: "${escapeYamlString(args.topic)}"`);
if (args.label) lines.push(`agentLabel: "${escapeYamlString(args.label)}"`);
if (args.modelKey) lines.push(`modelKey: "${escapeYamlString(args.modelKey)}"`);
if (args.lastAccessedAt) lines.push(`lastAccessedAt: ${args.lastAccessedAt}`);
// Serialized as a single-quoted JSON string: JSON.stringify emits no single
// quotes or raw control chars, so the value round-trips through the YAML
// parser (and our hand-rolled splitFrontmatter) verbatim.
if (args.usage) lines.push(`usage: '${JSON.stringify(args.usage)}'`);
lines.push("tags:");
lines.push(` - ${settings.defaultConversationTag}`);
lines.push("---");
lines.push("");
lines.push(args.chatContent);
return lines.join("\n");
}
}