From bcd6cf77df7b0b0fb0055b84cf9e657d81e5285e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 8 May 2026 12:17:44 +0100 Subject: [PATCH] refactor: use sqlite locators for transient sessions --- docs/refactor/database-first.md | 4 +++ .../native-command.think-autocomplete.test.ts | 2 ++ extensions/qa-channel/src/types.ts | 9 +++-- .../telegram/src/bot-native-commands.ts | 1 - extensions/voice-call/src/core-bridge.ts | 9 ++--- src/agents/live-model-switch.ts | 7 ++-- src/agents/transcript/session-manager.test.ts | 30 ++++++++++++++++ src/agents/transcript/session-manager.ts | 34 ++++++++++++++----- src/crestodian/assistant.ts | 6 +++- src/hooks/llm-slug-generator.ts | 26 ++++---------- 10 files changed, 84 insertions(+), 44 deletions(-) diff --git a/docs/refactor/database-first.md b/docs/refactor/database-first.md index e74c452264f..1dbbc530a54 100644 --- a/docs/refactor/database-first.md +++ b/docs/refactor/database-first.md @@ -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 diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts index fc896e294a1..f2cb718fd7b 100644 --- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts +++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts @@ -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", diff --git a/extensions/qa-channel/src/types.ts b/extensions/qa-channel/src/types.ts index 49c54801c35..d24ec9a02c4 100644 --- a/extensions/qa-channel/src/types.ts +++ b/extensions/qa-channel/src/types.ts @@ -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 = { diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts index 88e61d3ca18..2edd34ea929 100644 --- a/extensions/telegram/src/bot-native-commands.ts +++ b/extensions/telegram/src/bot-native-commands.ts @@ -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, diff --git a/extensions/voice-call/src/core-bridge.ts b/extensions/voice-call/src/core-bridge.ts index 8c3981db346..8ad47c2b599 100644 --- a/extensions/voice-call/src/core-bridge.ts +++ b/extensions/voice-call/src/core-bridge.ts @@ -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"]; diff --git a/src/agents/live-model-switch.ts b/src/agents/live-model-switch.ts index 8a02c00ef11..c39e2c033aa 100644 --- a/src/agents/live-model-switch.ts +++ b/src/agents/live-model-switch.ts @@ -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 { diff --git a/src/agents/transcript/session-manager.test.ts b/src/agents/transcript/session-manager.test.ts index e37156a9709..230cddeeb6e 100644 --- a/src/agents/transcript/session-manager.test.ts +++ b/src/agents/transcript/session-manager.test.ts @@ -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({ diff --git a/src/agents/transcript/session-manager.ts b/src/agents/transcript/session-manager.ts index a4dad605801..8fec4e8b362 100644 --- a/src/agents/transcript/session-manager.ts +++ b/src/agents/transcript/session-manager.ts @@ -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); } diff --git a/src/crestodian/assistant.ts b/src/crestodian/assistant.ts index ec01c3777ad..73242b60401 100644 --- a/src/crestodian/assistant.ts +++ b/src/crestodian/assistant.ts @@ -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": { diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index 7f656831c3f..22f96af15d3 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -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 { - 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 - } - } } }