This commit is contained in:
maxmaxrouge-rgb 2026-01-29 19:00:22 +00:00 committed by GitHub
commit 915aa7a646
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 333 additions and 0 deletions

View File

@ -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 = {

View File

@ -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();

View File

@ -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,

View File

@ -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`));
});
});

View File

@ -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<string, unknown>;
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<string, unknown> = {
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");

View File

@ -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,