diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 9c6ce0211..478a86979 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -89,6 +89,10 @@ export type CliBackendConfig = { imageMode?: "repeat" | "list"; /** Serialize runs for this CLI. */ serialize?: boolean; + /** Directory where this CLI stores session transcripts (for reading history). */ + transcriptDir?: string; + /** Pattern for transcript file names (use {sessionId} placeholder, defaults to {sessionId}.jsonl). */ + transcriptPattern?: string; }; export type AgentDefaultsConfig = { diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 4a8c80bcc..f74564eea 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -266,6 +266,8 @@ export const CliBackendSchema = z imageArg: z.string().optional(), imageMode: z.union([z.literal("repeat"), z.literal("list")]).optional(), serialize: z.boolean().optional(), + transcriptDir: z.string().optional(), + transcriptPattern: z.string().optional(), }) .strict(); diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 8c67767a6..ee9070c01 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -2,6 +2,7 @@ import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; import { loadConfig } from "../config/config.js"; import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js"; import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; +import { appendAssistantTranscriptEntry } from "./session-utils.fs.js"; import { loadSessionEntry } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; @@ -168,6 +169,22 @@ export function createAgentEventHandler({ chatRunState.buffers.delete(clientRunId); chatRunState.deltaSentAt.delete(clientRunId); if (jobState === "done") { + // Write transcript for CLI provider runs (which don't write their own gateway transcripts) + if (text) { + try { + const { storePath, entry } = loadSessionEntry(sessionKey); + const sessionId = entry?.sessionId ?? clientRunId; + appendAssistantTranscriptEntry({ + message: text, + sessionId, + storePath, + sessionFile: entry?.sessionFile, + createIfMissing: true, + }); + } catch { + // Transcript write failure shouldn't block the response + } + } const payload = { runId: clientRunId, sessionKey, diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 3cbbd4343..91835c637 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -3,9 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { + appendAssistantTranscriptEntry, readFirstUserMessageFromTranscript, readLastMessagePreviewFromTranscript, readSessionPreviewItemsFromTranscript, + resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; describe("readFirstUserMessageFromTranscript", () => { @@ -404,3 +406,197 @@ describe("readSessionPreviewItemsFromTranscript", () => { expect(result[0]?.text.endsWith("...")).toBe(true); }); }); + +describe("appendAssistantTranscriptEntry", () => { + let tmpDir: string; + let storePath: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-append-test-")); + storePath = path.join(tmpDir, "sessions.json"); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("creates transcript file when createIfMissing is true", () => { + const sessionId = "new-session-1"; + const result = appendAssistantTranscriptEntry({ + message: "Hello from assistant", + sessionId, + storePath, + createIfMissing: true, + }); + + expect(result.ok).toBe(true); + expect(result.messageId).toBeDefined(); + expect(result.message).toBeDefined(); + expect(result.message?.role).toBe("assistant"); + + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + expect(fs.existsSync(transcriptPath)).toBe(true); + + const content = fs.readFileSync(transcriptPath, "utf-8"); + const lines = content.split("\n").filter(Boolean); + expect(lines.length).toBe(2); // header + message + }); + + test("fails when transcript does not exist and createIfMissing is false", () => { + const sessionId = "nonexistent-session"; + const result = appendAssistantTranscriptEntry({ + message: "Hello", + sessionId, + storePath, + createIfMissing: false, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("not found"); + }); + + test("appends to existing transcript file", () => { + const sessionId = "existing-session"; + const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); + const header = JSON.stringify({ type: "session", version: 1, id: sessionId }); + fs.writeFileSync(transcriptPath, `${header}\n`, "utf-8"); + + const result = appendAssistantTranscriptEntry({ + message: "New assistant message", + sessionId, + storePath, + createIfMissing: false, + }); + + expect(result.ok).toBe(true); + + const content = fs.readFileSync(transcriptPath, "utf-8"); + const lines = content.split("\n").filter(Boolean); + expect(lines.length).toBe(2); + + const lastLine = JSON.parse(lines[1]); + expect(lastLine.message.role).toBe("assistant"); + expect(lastLine.message.content[0].text).toBe("New assistant message"); + }); + + test("includes label prefix when provided", () => { + const sessionId = "labeled-session"; + const result = appendAssistantTranscriptEntry({ + message: "Message content", + label: "webchat", + sessionId, + storePath, + createIfMissing: true, + }); + + expect(result.ok).toBe(true); + expect(result.message?.content[0].text).toContain("[webchat]"); + expect(result.message?.content[0].text).toContain("Message content"); + }); + + test("returns error when storePath is undefined and no sessionFile", () => { + const result = appendAssistantTranscriptEntry({ + message: "Hello", + sessionId: "test", + storePath: undefined, + createIfMissing: true, + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("not resolved"); + }); +}); + +describe("resolveSessionTranscriptCandidates with CLI config", () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-candidates-test-")); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("includes CLI transcript path when cliConfig is provided", () => { + const sessionId = "cli-session"; + const storePath = path.join(tmpDir, "sessions.json"); + + const candidates = resolveSessionTranscriptCandidates( + sessionId, + storePath, + undefined, + undefined, + { + transcriptDir: tmpDir, + transcriptPattern: "{sessionId}.jsonl", + }, + ); + + expect(candidates).toContain(path.join(tmpDir, `${sessionId}.jsonl`)); + }); + + test("uses default pattern when transcriptPattern is not provided", () => { + const sessionId = "default-pattern-session"; + const storePath = path.join(tmpDir, "sessions.json"); + + const candidates = resolveSessionTranscriptCandidates( + sessionId, + storePath, + undefined, + undefined, + { + transcriptDir: tmpDir, + }, + ); + + expect(candidates).toContain(path.join(tmpDir, `${sessionId}.jsonl`)); + }); + + test("expands tilde in transcriptDir", () => { + const sessionId = "tilde-session"; + const storePath = path.join(tmpDir, "sessions.json"); + const homeDir = os.homedir(); + + const candidates = resolveSessionTranscriptCandidates( + sessionId, + storePath, + undefined, + undefined, + { + transcriptDir: "~/.test-cli-logs", + }, + ); + + const expectedPath = path.join(homeDir, ".test-cli-logs", `${sessionId}.jsonl`); + expect(candidates).toContain(expectedPath); + }); + + test("does not add CLI path when cliConfig is undefined", () => { + const sessionId = "no-cli-session"; + const storePath = path.join(tmpDir, "sessions.json"); + + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath); + + // Should only have storePath-derived path and default clawdbot path + expect(candidates.length).toBe(2); + }); + + test("supports custom transcript pattern", () => { + const sessionId = "custom-pattern"; + const storePath = path.join(tmpDir, "sessions.json"); + + const candidates = resolveSessionTranscriptCandidates( + sessionId, + storePath, + undefined, + undefined, + { + transcriptDir: tmpDir, + transcriptPattern: "logs-{sessionId}.json", + }, + ); + + expect(candidates).toContain(path.join(tmpDir, `logs-${sessionId}.json`)); + }); +}); diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index d6453ace6..97f42888f 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -1,7 +1,9 @@ +import { randomUUID } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; import { resolveSessionTranscriptPath } from "../config/sessions.js"; import { stripEnvelope } from "./chat-sanitize.js"; import type { SessionPreviewItem } from "./session-utils.types.js"; @@ -32,11 +34,17 @@ export function readSessionMessages( return messages; } +export type CliTranscriptConfig = { + transcriptDir?: string; + transcriptPattern?: string; +}; + export function resolveSessionTranscriptCandidates( sessionId: string, storePath: string | undefined, sessionFile?: string, agentId?: string, + cliConfig?: CliTranscriptConfig, ): string[] { const candidates: string[] = []; if (sessionFile) candidates.push(sessionFile); @@ -47,6 +55,15 @@ export function resolveSessionTranscriptCandidates( if (agentId) { candidates.push(resolveSessionTranscriptPath(sessionId, agentId)); } + // Check CLI-configured transcript location if provided + if (cliConfig?.transcriptDir) { + const pattern = cliConfig.transcriptPattern ?? "{sessionId}.jsonl"; + const fileName = pattern.replace("{sessionId}", sessionId); + const expandedDir = cliConfig.transcriptDir.startsWith("~") + ? path.join(os.homedir(), cliConfig.transcriptDir.slice(1)) + : cliConfig.transcriptDir; + candidates.push(path.join(expandedDir, fileName)); + } candidates.push(path.join(os.homedir(), ".clawdbot", "sessions", `${sessionId}.jsonl`)); return candidates; } @@ -58,6 +75,101 @@ export function archiveFileOnDisk(filePath: string, reason: string): string { return archived; } +export type TranscriptAppendResult = { + ok: boolean; + messageId?: string; + message?: Record; + error?: string; +}; + +function resolveTranscriptWritePath(params: { + sessionId: string; + storePath: string | undefined; + sessionFile?: string; +}): string | null { + const { sessionId, storePath, sessionFile } = params; + if (sessionFile) return sessionFile; + if (!storePath) return null; + return path.join(path.dirname(storePath), `${sessionId}.jsonl`); +} + +function ensureTranscriptFile(params: { transcriptPath: string; sessionId: string }): { + ok: boolean; + error?: string; +} { + if (fs.existsSync(params.transcriptPath)) return { ok: true }; + try { + fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true }); + const header = { + type: "session", + version: CURRENT_SESSION_VERSION, + id: params.sessionId, + timestamp: new Date().toISOString(), + cwd: process.cwd(), + }; + fs.writeFileSync(params.transcriptPath, `${JSON.stringify(header)}\n`, "utf-8"); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +export function appendAssistantTranscriptEntry(params: { + message: string; + label?: string; + sessionId: string; + storePath: string | undefined; + sessionFile?: string; + createIfMissing?: boolean; +}): TranscriptAppendResult { + const transcriptPath = resolveTranscriptWritePath({ + sessionId: params.sessionId, + storePath: params.storePath, + sessionFile: params.sessionFile, + }); + if (!transcriptPath) { + return { ok: false, error: "transcript path not resolved" }; + } + + if (!fs.existsSync(transcriptPath)) { + if (!params.createIfMissing) { + return { ok: false, error: "transcript file not found" }; + } + const ensured = ensureTranscriptFile({ + transcriptPath, + sessionId: params.sessionId, + }); + if (!ensured.ok) { + return { ok: false, error: ensured.error ?? "failed to create transcript file" }; + } + } + + const now = Date.now(); + const messageId = randomUUID().slice(0, 8); + const labelPrefix = params.label ? `[${params.label}]\n\n` : ""; + const messageBody: Record = { + role: "assistant", + content: [{ type: "text", text: `${labelPrefix}${params.message}` }], + timestamp: now, + stopReason: "injected", + usage: { input: 0, output: 0, totalTokens: 0 }, + }; + const transcriptEntry = { + type: "message", + id: messageId, + timestamp: new Date(now).toISOString(), + message: messageBody, + }; + + try { + fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8"); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + + return { ok: true, messageId, message: transcriptEntry.message }; +} + function jsonUtf8Bytes(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value), "utf8"); diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 164be999e..24add4376 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -34,6 +34,7 @@ import type { } from "./session-utils.types.js"; export { + appendAssistantTranscriptEntry, archiveFileOnDisk, capArrayByJsonBytes, readFirstUserMessageFromTranscript, @@ -42,6 +43,7 @@ export { readSessionMessages, resolveSessionTranscriptCandidates, } from "./session-utils.fs.js"; +export type { CliTranscriptConfig, TranscriptAppendResult } from "./session-utils.fs.js"; export type { GatewayAgentRow, GatewaySessionRow,