refactor: use sqlite locators for transient sessions

This commit is contained in:
Peter Steinberger
2026-05-08 12:17:44 +01:00
parent a816bda7de
commit bcd6cf77df
10 changed files with 84 additions and 44 deletions

View File

@@ -200,6 +200,10 @@ The remaining cleanup is mostly consolidation and deletion:
locators to embedded agents instead of creating temporary or persisted
`session.jsonl` files under plugin state. The old `transcriptDir` option is
now a compatibility no-op.
- One-off slug generation and Crestodian planner runs now use virtual SQLite
transcript locators instead of creating temporary `session.jsonl` files.
`SessionManager.open()` preserves those locators instead of resolving them as
filesystem paths.
- Parent transcript fork decisions and fork creation no longer accept
`storePath` or `sessionsDir`; they use `{agentId, sessionId}` SQLite
transcript scope and derive any retained path metadata from the parent

View File

@@ -223,6 +223,7 @@ describe("discord native /think autocomplete", () => {
agentId: "main",
sessionKey: SESSION_KEY,
entry: {
sessionId: "think-dm",
updatedAt: Date.now(),
providerOverride: "openai-codex",
modelOverride: "gpt-5.4",
@@ -322,6 +323,7 @@ describe("discord native /think autocomplete", () => {
agentId: "main",
sessionKey: SESSION_KEY,
entry: {
sessionId: "think-guild",
updatedAt: Date.now(),
providerOverride: "anthropic",
modelOverride: "claude-opus-4-7",

View File

@@ -1,3 +1,5 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
type QaChannelActionConfig = {
messages?: boolean;
reactions?: boolean;
@@ -32,13 +34,10 @@ type QaChannelConfig = QaChannelAccountConfig & {
defaultAccount?: string;
};
export type CoreConfig = {
channels?: {
export type CoreConfig = OpenClawConfig & {
channels?: OpenClawConfig["channels"] & {
"qa-channel"?: QaChannelConfig;
};
session?: {
store?: string;
};
};
export type ResolvedQaChannelAccount = {

View File

@@ -190,7 +190,6 @@ async function resolveTelegramCommandSessionFile(params: {
const persisted = await resolveAndPersistSessionFile({
sessionId,
sessionKey: resolved.normalizedKey,
sessionStore: resolved.existing ? { [resolved.normalizedKey]: resolved.existing } : {},
sessionEntry: resolved.existing,
agentId: params.agentId,
fallbackSessionFile,

View File

@@ -1,14 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import type { OpenClawPluginApi } from "../api.js";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = {
session?: {
store?: string;
};
messages?: {
export type CoreConfig = OpenClawConfig & {
messages?: OpenClawConfig["messages"] & {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
};
export type CoreAgentDeps = OpenClawPluginApi["runtime"]["agent"];

View File

@@ -1,5 +1,6 @@
import { getSessionEntry, upsertSessionEntry } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import {
normalizeStoredOverrideModel,
@@ -31,7 +32,7 @@ function readLiveSessionEntry(params: {
}
export function resolveLiveSessionModelSelection(params: {
cfg?: { session?: { store?: string } } | undefined;
cfg?: OpenClawConfig | undefined;
sessionKey?: string;
agentId?: string;
defaultProvider: string;
@@ -153,7 +154,7 @@ export function shouldTrackPersistedLiveSessionModelSelection(
* user-initiated `/model` switches and system-initiated fallback rotations.
*/
export function shouldSwitchToLiveModel(params: {
cfg?: { session?: { store?: string } } | undefined;
cfg?: OpenClawConfig | undefined;
sessionKey?: string;
agentId?: string;
defaultProvider: string;
@@ -210,7 +211,7 @@ export function shouldSwitchToLiveModel(params: {
* subsequent retry iterations do not re-trigger the switch.
*/
export async function clearLiveModelSwitchPending(params: {
cfg?: { session?: { store?: string } } | undefined;
cfg?: OpenClawConfig | undefined;
sessionKey?: string;
agentId?: string;
}): Promise<void> {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSqliteSessionTranscriptLocator } from "../../config/sessions/paths.js";
import {
loadSqliteSessionTranscriptEvents,
resolveSqliteSessionTranscriptScopeForPath,
@@ -92,6 +93,35 @@ describe("TranscriptSessionManager", () => {
]);
});
it("opens virtual sqlite transcript locators without resolving them as filesystem paths", async () => {
await makeTempSessionFile();
const sessionFile = createSqliteSessionTranscriptLocator({
agentId: "main",
sessionId: "virtual-session",
});
const sessionManager = openTranscriptSessionManager({
sessionFile,
sessionId: "virtual-session",
cwd: "/tmp/workspace",
});
expect(sessionManager.getSessionFile()).toBe(sessionFile);
expect(
resolveSqliteSessionTranscriptScopeForPath({ transcriptPath: sessionFile }),
).toMatchObject({
agentId: "main",
sessionId: "virtual-session",
});
expect(readSessionEntries(sessionFile)).toMatchObject([
{
type: "session",
id: "virtual-session",
cwd: "/tmp/workspace",
},
]);
});
it("persists initial user messages synchronously before the first assistant message", async () => {
const sessionFile = await makeTempSessionFile();
const sessionManager = openTranscriptSessionManager({

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { isSqliteSessionTranscriptLocator } from "../../config/sessions/paths.js";
import {
appendSqliteSessionTranscriptEvent,
listSqliteSessionTranscriptFiles,
@@ -55,6 +56,18 @@ function resolveDefaultSessionDir(cwd: string): string {
return path.join(os.homedir(), ".openclaw", "sessions", encodeSessionCwd(cwd));
}
function normalizeSessionFileIdentifier(sessionFile: string): string {
const trimmed = sessionFile.trim();
return isSqliteSessionTranscriptLocator(trimmed) ? trimmed : path.resolve(trimmed);
}
function resolveSessionDirForIdentifier(sessionFile: string, sessionDir?: string): string {
if (sessionDir) {
return path.resolve(sessionDir);
}
return isSqliteSessionTranscriptLocator(sessionFile) ? "" : path.dirname(sessionFile);
}
function resolveAgentIdFromSessionPath(sessionFile: string): string {
void sessionFile;
return DEFAULT_AGENT_ID;
@@ -102,7 +115,10 @@ function loadTranscriptState(params: { sessionFile: string; sessionId?: string;
state: TranscriptState;
scope: TranscriptSqliteScope;
} {
const transcriptPath = path.resolve(params.sessionFile);
const sessionFile = params.sessionFile.trim();
const transcriptPath = isSqliteSessionTranscriptLocator(sessionFile)
? sessionFile
: path.resolve(sessionFile);
const existingScope = resolveSqliteSessionTranscriptScopeForPath({ transcriptPath });
const scope = {
agentId: existingScope?.agentId ?? resolveAgentIdFromSessionPath(transcriptPath),
@@ -270,8 +286,10 @@ export class TranscriptSessionManager implements SessionManager {
persist: boolean;
sqliteScope?: TranscriptSqliteScope;
}) {
this.sessionFile = params.sessionFile ? path.resolve(params.sessionFile) : undefined;
this.sessionDir = path.resolve(params.sessionDir);
this.sessionFile = params.sessionFile
? normalizeSessionFileIdentifier(params.sessionFile)
: undefined;
this.sessionDir = params.sessionDir ? path.resolve(params.sessionDir) : "";
this.state = params.state;
this.persist = params.persist;
this.sqliteScope = params.sqliteScope;
@@ -283,14 +301,14 @@ export class TranscriptSessionManager implements SessionManager {
cwd?: string;
sessionDir?: string;
}): TranscriptSessionManager {
const sessionFile = path.resolve(params.sessionFile);
const sessionFile = normalizeSessionFileIdentifier(params.sessionFile);
const loaded = loadTranscriptState({
sessionFile,
sessionId: params.sessionId,
cwd: params.cwd,
});
return new TranscriptSessionManager({
sessionDir: params.sessionDir ? path.resolve(params.sessionDir) : path.dirname(sessionFile),
sessionDir: resolveSessionDirForIdentifier(sessionFile, params.sessionDir),
sessionFile,
persist: true,
state: loaded.state,
@@ -403,8 +421,8 @@ export class TranscriptSessionManager implements SessionManager {
}
setSessionFile(sessionFile: string): void {
this.sessionFile = path.resolve(sessionFile);
this.sessionDir = path.dirname(this.sessionFile);
this.sessionFile = normalizeSessionFileIdentifier(sessionFile);
this.sessionDir = resolveSessionDirForIdentifier(this.sessionFile);
this.persist = true;
const loaded = loadTranscriptState({
sessionFile: this.sessionFile,
@@ -427,7 +445,7 @@ export class TranscriptSessionManager implements SessionManager {
this.sqliteScope = {
agentId: resolveAgentIdFromSessionPath(this.sessionFile),
sessionId: header.id,
transcriptPath: path.resolve(this.sessionFile),
transcriptPath: normalizeSessionFileIdentifier(this.sessionFile),
};
persistFullTranscriptStateToSqlite(this.sqliteScope, this.state);
}

View File

@@ -9,6 +9,7 @@ import {
prepareSimpleCompletionModelForAgent,
} from "../agents/simple-completion-runtime.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { createSqliteSessionTranscriptLocator } from "../config/sessions/paths.js";
import { selectCrestodianLocalPlannerBackends } from "./assistant-backends.js";
import {
CRESTODIAN_ASSISTANT_MAX_TOKENS,
@@ -181,8 +182,11 @@ async function runLocalRuntimePlanner(
const tempDir = await (params.deps?.createTempDir ?? createTempPlannerDir)();
try {
const runId = `crestodian-planner-${randomUUID()}`;
const sessionFile = path.join(tempDir, "session.jsonl");
const sessionId = `${runId}-session`;
const sessionFile = createSqliteSessionTranscriptLocator({
agentId: "crestodian",
sessionId,
});
const sessionKey = `temp:crestodian-planner:${runId}`;
switch (backend.runner) {
case "cli": {

View File

@@ -2,9 +2,7 @@
* LLM-based slug generator for session memory filenames
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import {
resolveDefaultAgentId,
resolveAgentWorkspaceDir,
@@ -13,6 +11,7 @@ import {
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { createSqliteSessionTranscriptLocator } from "../config/sessions/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
@@ -35,16 +34,12 @@ export async function generateSlugViaLLM(params: {
sessionContent: string;
cfg: OpenClawConfig;
}): Promise<string | null> {
let tempSessionFile: string | null = null;
try {
const agentId = resolveDefaultAgentId(params.cfg);
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId);
const agentDir = resolveAgentDir(params.cfg, agentId);
// Create a temporary session file for this one-off LLM call
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-slug-"));
tempSessionFile = path.join(tempDir, "session.jsonl");
const sessionId = `slug-generator-${randomUUID()}`;
const sessionFile = createSqliteSessionTranscriptLocator({ agentId, sessionId });
const prompt = `Based on this conversation, generate a short 1-2 word filename slug (lowercase, hyphen-separated, no file extension).
@@ -60,10 +55,10 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design",
const timeoutMs = resolveSlugGeneratorTimeoutMs(params.cfg);
const result = await runEmbeddedPiAgent({
sessionId: `slug-generator-${Date.now()}`,
sessionId,
sessionKey: "temp:slug-generator",
agentId,
sessionFile: tempSessionFile,
sessionFile,
workspaceDir,
agentDir,
config: params.cfg,
@@ -95,14 +90,5 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design",
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
log.error(`Failed to generate slug: ${message}`);
return null;
} finally {
// Clean up temporary session file
if (tempSessionFile) {
try {
await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}
}
}