feat: add sessions preview rpc and menu prewarm
This commit is contained in:
@@ -69,6 +69,7 @@ actor GatewayConnection {
|
|||||||
case channelsLogout = "channels.logout"
|
case channelsLogout = "channels.logout"
|
||||||
case modelsList = "models.list"
|
case modelsList = "models.list"
|
||||||
case chatHistory = "chat.history"
|
case chatHistory = "chat.history"
|
||||||
|
case sessionsPreview = "sessions.preview"
|
||||||
case chatSend = "chat.send"
|
case chatSend = "chat.send"
|
||||||
case chatAbort = "chat.abort"
|
case chatAbort = "chat.abort"
|
||||||
case skillsStatus = "skills.status"
|
case skillsStatus = "skills.status"
|
||||||
@@ -540,6 +541,30 @@ extension GatewayConnection {
|
|||||||
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
return try await self.requestDecoded(method: .skillsUpdate, params: params)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sessions
|
||||||
|
|
||||||
|
func sessionsPreview(
|
||||||
|
keys: [String],
|
||||||
|
limit: Int? = nil,
|
||||||
|
maxChars: Int? = nil,
|
||||||
|
timeoutMs: Int? = nil) async throws -> ClawdbotSessionsPreviewPayload
|
||||||
|
{
|
||||||
|
let resolvedKeys = keys
|
||||||
|
.map { self.canonicalizeSessionKey($0) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
if resolvedKeys.isEmpty {
|
||||||
|
return ClawdbotSessionsPreviewPayload(ts: 0, previews: [])
|
||||||
|
}
|
||||||
|
var params: [String: AnyCodable] = ["keys": AnyCodable(resolvedKeys)]
|
||||||
|
if let limit { params["limit"] = AnyCodable(limit) }
|
||||||
|
if let maxChars { params["maxChars"] = AnyCodable(maxChars) }
|
||||||
|
let timeout = timeoutMs.map { Double($0) }
|
||||||
|
return try await self.requestDecoded(
|
||||||
|
method: .sessionsPreview,
|
||||||
|
params: params,
|
||||||
|
timeoutMs: timeout)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Chat
|
// MARK: - Chat
|
||||||
|
|
||||||
func chatHistory(
|
func chatHistory(
|
||||||
|
|||||||
@@ -188,6 +188,13 @@ extension MenuSessionsInjector {
|
|||||||
if rhs.key == mainKey { return false }
|
if rhs.key == mainKey { return false }
|
||||||
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
|
||||||
}
|
}
|
||||||
|
if !rows.isEmpty {
|
||||||
|
let previewKeys = rows.prefix(20).map(\.key)
|
||||||
|
let task = Task {
|
||||||
|
await SessionMenuPreviewLoader.prewarm(sessionKeys: previewKeys, maxItems: 10)
|
||||||
|
}
|
||||||
|
self.previewTasks.append(task)
|
||||||
|
}
|
||||||
|
|
||||||
let headerItem = NSMenuItem()
|
let headerItem = NSMenuItem()
|
||||||
headerItem.tag = self.tag
|
headerItem.tag = self.tag
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import ClawdbotChatUI
|
import ClawdbotChatUI
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import ClawdbotProtocol
|
||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
@@ -31,24 +32,24 @@ actor SessionPreviewCache {
|
|||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
let items: [SessionPreviewItem]
|
let snapshot: SessionMenuPreviewSnapshot
|
||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
private var entries: [String: CacheEntry] = [:]
|
private var entries: [String: CacheEntry] = [:]
|
||||||
|
|
||||||
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
|
func cachedSnapshot(for sessionKey: String, maxAge: TimeInterval) -> SessionMenuPreviewSnapshot? {
|
||||||
guard let entry = self.entries[sessionKey] else { return nil }
|
guard let entry = self.entries[sessionKey] else { return nil }
|
||||||
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
|
||||||
return entry.items
|
return entry.snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
func store(items: [SessionPreviewItem], for sessionKey: String) {
|
func store(snapshot: SessionMenuPreviewSnapshot, for sessionKey: String) {
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
|
func lastSnapshot(for sessionKey: String) -> SessionMenuPreviewSnapshot? {
|
||||||
self.entries[sessionKey]?.items
|
self.entries[sessionKey]?.snapshot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +100,12 @@ actor SessionPreviewLimiter {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
extension SessionPreviewCache {
|
extension SessionPreviewCache {
|
||||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
func _testSet(
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
snapshot: SessionMenuPreviewSnapshot,
|
||||||
|
for sessionKey: String,
|
||||||
|
updatedAt: Date = Date())
|
||||||
|
{
|
||||||
|
self.entries[sessionKey] = CacheEntry(snapshot: snapshot, updatedAt: updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func _testReset() {
|
func _testReset() {
|
||||||
@@ -219,50 +224,44 @@ enum SessionMenuPreviewLoader {
|
|||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||||
|
private static let previewMaxChars = 240
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
var errorDescription: String? { "preview timeout" }
|
var errorDescription: String? { "preview timeout" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func prewarm(sessionKeys: [String], maxItems: Int) async {
|
||||||
|
let keys = self.uniqueKeys(sessionKeys)
|
||||||
|
guard !keys.isEmpty else { return }
|
||||||
|
do {
|
||||||
|
let payload = try await self.requestPreview(keys: keys, maxItems: maxItems)
|
||||||
|
await self.cache(payload: payload, maxItems: maxItems)
|
||||||
|
} catch {
|
||||||
|
if self.isUnknownMethodError(error) { return }
|
||||||
|
let errorDescription = String(describing: error)
|
||||||
|
Self.logger.debug(
|
||||||
|
"Session preview prewarm failed count=\(keys.count, privacy: .public) " +
|
||||||
|
"error=\(errorDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
if let cached = await SessionPreviewCache.shared.cachedSnapshot(
|
||||||
return self.snapshot(from: cached)
|
for: sessionKey,
|
||||||
}
|
maxAge: cacheMaxAgeSeconds)
|
||||||
|
{
|
||||||
let isConnected = await MainActor.run {
|
return cached
|
||||||
if case .connected = ControlChannel.shared.state { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
guard isConnected else {
|
|
||||||
if let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey) {
|
|
||||||
return Self.snapshot(from: fallback)
|
|
||||||
}
|
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Gateway disconnected"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
let snapshot = try await self.fetchSnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: sessionKey)
|
||||||
try await AsyncTimeout.withTimeout(
|
return snapshot
|
||||||
seconds: self.previewTimeoutSeconds,
|
|
||||||
onTimeout: { PreviewTimeoutError() },
|
|
||||||
operation: {
|
|
||||||
try await GatewayConnection.shared.chatHistory(
|
|
||||||
sessionKey: sessionKey,
|
|
||||||
limit: self.previewLimit(for: maxItems),
|
|
||||||
timeoutMs: timeoutMs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
|
||||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
|
||||||
return Self.snapshot(from: built)
|
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||||
} catch {
|
} catch {
|
||||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
if let fallback = await SessionPreviewCache.shared.lastSnapshot(for: sessionKey) {
|
||||||
if let fallback {
|
return fallback
|
||||||
return Self.snapshot(from: fallback)
|
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
@@ -272,18 +271,120 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func fetchSnapshot(sessionKey: String, maxItems: Int) async throws -> SessionMenuPreviewSnapshot {
|
||||||
|
do {
|
||||||
|
let payload = try await self.requestPreview(keys: [sessionKey], maxItems: maxItems)
|
||||||
|
if let entry = payload.previews.first(where: { $0.key == sessionKey }) ?? payload.previews.first {
|
||||||
|
return self.snapshot(from: entry, maxItems: maxItems)
|
||||||
|
}
|
||||||
|
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||||
|
} catch {
|
||||||
|
if self.isUnknownMethodError(error) {
|
||||||
|
return try await self.fetchHistorySnapshot(sessionKey: sessionKey, maxItems: maxItems)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requestPreview(
|
||||||
|
keys: [String],
|
||||||
|
maxItems: Int) async throws -> ClawdbotSessionsPreviewPayload
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
|
return try await SessionPreviewLimiter.shared.withPermit {
|
||||||
|
try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.sessionsPreview(
|
||||||
|
keys: keys,
|
||||||
|
limit: boundedItems,
|
||||||
|
maxChars: self.previewMaxChars,
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fetchHistorySnapshot(
|
||||||
|
sessionKey: String,
|
||||||
|
maxItems: Int) async throws -> SessionMenuPreviewSnapshot
|
||||||
|
{
|
||||||
|
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||||
|
let payload = try await SessionPreviewLimiter.shared.withPermit {
|
||||||
|
try await AsyncTimeout.withTimeout(
|
||||||
|
seconds: self.previewTimeoutSeconds,
|
||||||
|
onTimeout: { PreviewTimeoutError() },
|
||||||
|
operation: {
|
||||||
|
try await GatewayConnection.shared.chatHistory(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
limit: self.previewLimit(for: maxItems),
|
||||||
|
timeoutMs: timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||||
|
return Self.snapshot(from: built)
|
||||||
|
}
|
||||||
|
|
||||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func snapshot(
|
||||||
|
from entry: ClawdbotSessionPreviewEntry,
|
||||||
|
maxItems: Int) -> SessionMenuPreviewSnapshot
|
||||||
|
{
|
||||||
|
let items = self.previewItems(from: entry, maxItems: maxItems)
|
||||||
|
let normalized = entry.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
switch normalized {
|
||||||
|
case "ok":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||||
|
case "empty":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .empty)
|
||||||
|
case "missing":
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .error("Session missing"))
|
||||||
|
default:
|
||||||
|
return SessionMenuPreviewSnapshot(items: items, status: .error("Preview unavailable"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cache(payload: ClawdbotSessionsPreviewPayload, maxItems: Int) async {
|
||||||
|
for entry in payload.previews {
|
||||||
|
let snapshot = self.snapshot(from: entry, maxItems: maxItems)
|
||||||
|
await SessionPreviewCache.shared.store(snapshot: snapshot, for: entry.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static func previewLimit(for maxItems: Int) -> Int {
|
private static func previewLimit(for maxItems: Int) -> Int {
|
||||||
min(max(maxItems * 3, 20), 120)
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
return min(max(boundedItems * 3, 20), 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMaxItems(_ maxItems: Int) -> Int {
|
||||||
|
max(1, min(maxItems, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewItems(
|
||||||
|
from entry: ClawdbotSessionPreviewEntry,
|
||||||
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
|
let built: [SessionPreviewItem] = entry.items.enumerated().compactMap { index, item in
|
||||||
|
let text = item.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
let role = self.previewRoleFromRaw(item.role)
|
||||||
|
return SessionPreviewItem(id: "\(entry.key)-\(index)", role: role, text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
let trimmed = built.suffix(boundedItems)
|
||||||
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
{
|
{
|
||||||
|
let boundedItems = self.normalizeMaxItems(maxItems)
|
||||||
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
|
||||||
let messages = self.decodeMessages(raw)
|
let messages = self.decodeMessages(raw)
|
||||||
let built = messages.compactMap { message -> SessionPreviewItem? in
|
let built = messages.compactMap { message -> SessionPreviewItem? in
|
||||||
@@ -294,7 +395,7 @@ enum SessionMenuPreviewLoader {
|
|||||||
return SessionPreviewItem(id: id, role: role, text: text)
|
return SessionPreviewItem(id: id, role: role, text: text)
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = built.suffix(maxItems)
|
let trimmed = built.suffix(boundedItems)
|
||||||
return Array(trimmed.reversed())
|
return Array(trimmed.reversed())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,12 +408,16 @@ enum SessionMenuPreviewLoader {
|
|||||||
|
|
||||||
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
|
||||||
if isTool { return .tool }
|
if isTool { return .tool }
|
||||||
|
return self.previewRoleFromRaw(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func previewRoleFromRaw(_ raw: String) -> PreviewRole {
|
||||||
switch raw.lowercased() {
|
switch raw.lowercased() {
|
||||||
case "user": return .user
|
case "user": .user
|
||||||
case "assistant": return .assistant
|
case "assistant": .assistant
|
||||||
case "system": return .system
|
case "system": .system
|
||||||
case "tool": return .tool
|
case "tool": .tool
|
||||||
default: return .other
|
default: .other
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,4 +480,16 @@ enum SessionMenuPreviewLoader {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func uniqueKeys(_ keys: [String]) -> [String] {
|
||||||
|
let trimmed = keys.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
return self.dedupePreservingOrder(trimmed.filter { !$0.isEmpty })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isUnknownMethodError(_ error: Error) -> Bool {
|
||||||
|
guard let response = error as? GatewayResponseError else { return false }
|
||||||
|
guard response.code == ErrorCode.invalidRequest.rawValue else { return false }
|
||||||
|
let message = response.message.lowercased()
|
||||||
|
return message.contains("unknown method")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,22 @@ struct SessionMenuPreviewTests {
|
|||||||
@Test func loaderReturnsCachedItems() async {
|
@Test func loaderReturnsCachedItems() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
let snapshot = SessionMenuPreviewSnapshot(items: items, status: .ready)
|
||||||
|
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||||
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(snapshot.status == .ready)
|
#expect(loaded.status == .ready)
|
||||||
#expect(snapshot.items.count == 1)
|
#expect(loaded.items.count == 1)
|
||||||
#expect(snapshot.items.first?.text == "Hi")
|
#expect(loaded.items.first?.text == "Hi")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||||
await SessionPreviewCache.shared._testReset()
|
await SessionPreviewCache.shared._testReset()
|
||||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
let snapshot = SessionMenuPreviewSnapshot(items: [], status: .empty)
|
||||||
|
await SessionPreviewCache.shared._testSet(snapshot: snapshot, for: "main")
|
||||||
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
let loaded = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||||
#expect(snapshot.status == .empty)
|
#expect(loaded.status == .empty)
|
||||||
#expect(snapshot.items.isEmpty)
|
#expect(loaded.items.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,6 +235,22 @@ public struct ClawdbotChatHistoryPayload: Codable, Sendable {
|
|||||||
public let thinkingLevel: String?
|
public let thinkingLevel: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSessionPreviewItem: Codable, Hashable, Sendable {
|
||||||
|
public let role: String
|
||||||
|
public let text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSessionPreviewEntry: Codable, Sendable {
|
||||||
|
public let key: String
|
||||||
|
public let status: String
|
||||||
|
public let items: [ClawdbotSessionPreviewItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ClawdbotSessionsPreviewPayload: Codable, Sendable {
|
||||||
|
public let ts: Int
|
||||||
|
public let previews: [ClawdbotSessionPreviewEntry]
|
||||||
|
}
|
||||||
|
|
||||||
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
public struct ClawdbotChatSendResponse: Codable, Sendable {
|
||||||
public let runId: String
|
public let runId: String
|
||||||
public let status: String
|
public let status: String
|
||||||
|
|||||||
@@ -140,6 +140,8 @@ import {
|
|||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
type SessionsPatchParams,
|
type SessionsPatchParams,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
type SessionsPreviewParams,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
type SessionsResetParams,
|
type SessionsResetParams,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
type SessionsResolveParams,
|
type SessionsResolveParams,
|
||||||
@@ -229,6 +231,9 @@ export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams
|
|||||||
);
|
);
|
||||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||||
|
export const validateSessionsPreviewParams = ajv.compile<SessionsPreviewParams>(
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
|
);
|
||||||
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
);
|
);
|
||||||
@@ -376,6 +381,7 @@ export {
|
|||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
@@ -488,6 +494,7 @@ export type {
|
|||||||
NodeInvokeResultParams,
|
NodeInvokeResultParams,
|
||||||
NodeEventParams,
|
NodeEventParams,
|
||||||
SessionsListParams,
|
SessionsListParams,
|
||||||
|
SessionsPreviewParams,
|
||||||
SessionsResolveParams,
|
SessionsResolveParams,
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
SessionsResetParams,
|
SessionsResetParams,
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ import {
|
|||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
@@ -155,6 +156,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodeEventParams: NodeEventParamsSchema,
|
NodeEventParams: NodeEventParamsSchema,
|
||||||
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
|
SessionsPreviewParams: SessionsPreviewParamsSchema,
|
||||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
SessionsResetParams: SessionsResetParamsSchema,
|
SessionsResetParams: SessionsResetParamsSchema,
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ export const SessionsListParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SessionsPreviewParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
keys: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||||
|
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||||
|
maxChars: Type.Optional(Type.Integer({ minimum: 20 })),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const SessionsResolveParamsSchema = Type.Object(
|
export const SessionsResolveParamsSchema = Type.Object(
|
||||||
{
|
{
|
||||||
key: Type.Optional(NonEmptyString),
|
key: Type.Optional(NonEmptyString),
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ import type {
|
|||||||
SessionsDeleteParamsSchema,
|
SessionsDeleteParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
SessionsPreviewParamsSchema,
|
||||||
SessionsResetParamsSchema,
|
SessionsResetParamsSchema,
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
} from "./sessions.js";
|
} from "./sessions.js";
|
||||||
@@ -144,6 +145,7 @@ export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
|||||||
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
||||||
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
||||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
|
export type SessionsPreviewParams = Static<typeof SessionsPreviewParamsSchema>;
|
||||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const BASE_METHODS = [
|
|||||||
"voicewake.get",
|
"voicewake.get",
|
||||||
"voicewake.set",
|
"voicewake.set",
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
|
"sessions.preview",
|
||||||
"sessions.patch",
|
"sessions.patch",
|
||||||
"sessions.reset",
|
"sessions.reset",
|
||||||
"sessions.delete",
|
"sessions.delete",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const READ_METHODS = new Set([
|
|||||||
"skills.status",
|
"skills.status",
|
||||||
"voicewake.get",
|
"voicewake.get",
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
|
"sessions.preview",
|
||||||
"cron.list",
|
"cron.list",
|
||||||
"cron.status",
|
"cron.status",
|
||||||
"cron.runs",
|
"cron.runs",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { stopSubagentsForRequester } from "../../auto-reply/reply/abort.js";
|
|||||||
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
|
import { clearSessionQueues } from "../../auto-reply/reply/queue.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { loadConfig } from "../../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
loadSessionStore,
|
||||||
snapshotSessionOrigin,
|
snapshotSessionOrigin,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
type SessionEntry,
|
type SessionEntry,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
validateSessionsDeleteParams,
|
validateSessionsDeleteParams,
|
||||||
validateSessionsListParams,
|
validateSessionsListParams,
|
||||||
validateSessionsPatchParams,
|
validateSessionsPatchParams,
|
||||||
|
validateSessionsPreviewParams,
|
||||||
validateSessionsResetParams,
|
validateSessionsResetParams,
|
||||||
validateSessionsResolveParams,
|
validateSessionsResolveParams,
|
||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
@@ -27,9 +29,12 @@ import {
|
|||||||
listSessionsFromStore,
|
listSessionsFromStore,
|
||||||
loadCombinedSessionStoreForGateway,
|
loadCombinedSessionStoreForGateway,
|
||||||
loadSessionEntry,
|
loadSessionEntry,
|
||||||
|
readSessionPreviewItemsFromTranscript,
|
||||||
resolveGatewaySessionStoreTarget,
|
resolveGatewaySessionStoreTarget,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
type SessionsPatchResult,
|
type SessionsPatchResult,
|
||||||
|
type SessionsPreviewEntry,
|
||||||
|
type SessionsPreviewResult,
|
||||||
} from "../session-utils.js";
|
} from "../session-utils.js";
|
||||||
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
import { applySessionsPatchToStore } from "../sessions-patch.js";
|
||||||
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
import { resolveSessionKeyFromResolveParams } from "../sessions-resolve.js";
|
||||||
@@ -59,6 +64,74 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
},
|
},
|
||||||
|
"sessions.preview": ({ params, respond }) => {
|
||||||
|
if (!validateSessionsPreviewParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid sessions.preview params: ${formatValidationErrors(
|
||||||
|
validateSessionsPreviewParams.errors,
|
||||||
|
)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const p = params as import("../protocol/index.js").SessionsPreviewParams;
|
||||||
|
const keysRaw = Array.isArray(p.keys) ? p.keys : [];
|
||||||
|
const keys = keysRaw
|
||||||
|
.map((key) => String(key ?? "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 64);
|
||||||
|
const limit =
|
||||||
|
typeof p.limit === "number" && Number.isFinite(p.limit) ? Math.max(1, p.limit) : 12;
|
||||||
|
const maxChars =
|
||||||
|
typeof p.maxChars === "number" && Number.isFinite(p.maxChars)
|
||||||
|
? Math.max(20, p.maxChars)
|
||||||
|
: 240;
|
||||||
|
|
||||||
|
if (keys.length === 0) {
|
||||||
|
respond(true, { ts: Date.now(), previews: [] } satisfies SessionsPreviewResult, undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const storeCache = new Map<string, Record<string, SessionEntry>>();
|
||||||
|
const previews: SessionsPreviewEntry[] = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
const target = resolveGatewaySessionStoreTarget({ cfg, key });
|
||||||
|
const store = storeCache.get(target.storePath) ?? loadSessionStore(target.storePath);
|
||||||
|
storeCache.set(target.storePath, store);
|
||||||
|
const entry =
|
||||||
|
target.storeKeys.map((candidate) => store[candidate]).find(Boolean) ??
|
||||||
|
store[target.canonicalKey];
|
||||||
|
if (!entry?.sessionId) {
|
||||||
|
previews.push({ key, status: "missing", items: [] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const items = readSessionPreviewItemsFromTranscript(
|
||||||
|
entry.sessionId,
|
||||||
|
target.storePath,
|
||||||
|
entry.sessionFile,
|
||||||
|
target.agentId,
|
||||||
|
limit,
|
||||||
|
maxChars,
|
||||||
|
);
|
||||||
|
previews.push({
|
||||||
|
key,
|
||||||
|
status: items.length > 0 ? "ok" : "empty",
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
previews.push({ key, status: "error", items: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(true, { ts: Date.now(), previews } satisfies SessionsPreviewResult, undefined);
|
||||||
|
},
|
||||||
"sessions.resolve": ({ params, respond }) => {
|
"sessions.resolve": ({ params, respond }) => {
|
||||||
if (!validateSessionsResolveParams(params)) {
|
if (!validateSessionsResolveParams(params)) {
|
||||||
respond(
|
respond(
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ describe("gateway server sessions", () => {
|
|||||||
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
|
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
|
"sessions.preview",
|
||||||
"sessions.patch",
|
"sessions.patch",
|
||||||
"sessions.reset",
|
"sessions.reset",
|
||||||
"sessions.delete",
|
"sessions.delete",
|
||||||
@@ -338,6 +339,53 @@ describe("gateway server sessions", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("sessions.preview returns transcript previews", async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-preview-"));
|
||||||
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
testState.sessionStorePath = storePath;
|
||||||
|
const sessionId = "sess-preview";
|
||||||
|
const transcriptPath = path.join(dir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
||||||
|
JSON.stringify({
|
||||||
|
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
|
||||||
|
];
|
||||||
|
await fs.writeFile(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
await writeSessionStore({
|
||||||
|
entries: {
|
||||||
|
main: {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
const preview = await rpcReq<{
|
||||||
|
previews: Array<{
|
||||||
|
key: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<{ role: string; text: string }>;
|
||||||
|
}>;
|
||||||
|
}>(ws, "sessions.preview", { keys: ["main"], limit: 3, maxChars: 120 });
|
||||||
|
|
||||||
|
expect(preview.ok).toBe(true);
|
||||||
|
const entry = preview.payload?.previews[0];
|
||||||
|
expect(entry?.key).toBe("main");
|
||||||
|
expect(entry?.status).toBe("ok");
|
||||||
|
expect(entry?.items.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
||||||
|
expect(entry?.items[1]?.text).toContain("call weather");
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("sessions.delete rejects main and aborts active runs", async () => {
|
test("sessions.delete rejects main and aborts active runs", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||||
const storePath = path.join(dir, "sessions.json");
|
const storePath = path.join(dir, "sessions.json");
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|||||||
import {
|
import {
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
readLastMessagePreviewFromTranscript,
|
readLastMessagePreviewFromTranscript,
|
||||||
|
readSessionPreviewItemsFromTranscript,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
|
|
||||||
describe("readFirstUserMessageFromTranscript", () => {
|
describe("readFirstUserMessageFromTranscript", () => {
|
||||||
@@ -341,3 +342,65 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
|||||||
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
expect(result).toBe("Valid UTF-8: 你好世界 🌍");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("readSessionPreviewItemsFromTranscript", () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let storePath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-session-preview-test-"));
|
||||||
|
storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns recent preview items with tool summary", () => {
|
||||||
|
const sessionId = "preview-session";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const lines = [
|
||||||
|
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||||
|
JSON.stringify({ message: { role: "user", content: "Hello" } }),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Hi" } }),
|
||||||
|
JSON.stringify({
|
||||||
|
message: { role: "assistant", content: [{ type: "toolcall", name: "weather" }] },
|
||||||
|
}),
|
||||||
|
JSON.stringify({ message: { role: "assistant", content: "Forecast ready" } }),
|
||||||
|
];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const result = readSessionPreviewItemsFromTranscript(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
3,
|
||||||
|
120,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.map((item) => item.role)).toEqual(["assistant", "tool", "assistant"]);
|
||||||
|
expect(result[1]?.text).toContain("call weather");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("truncates preview text to max chars", () => {
|
||||||
|
const sessionId = "preview-truncate";
|
||||||
|
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||||
|
const longText = "a".repeat(60);
|
||||||
|
const lines = [JSON.stringify({ message: { role: "assistant", content: longText } })];
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const result = readSessionPreviewItemsFromTranscript(
|
||||||
|
sessionId,
|
||||||
|
storePath,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
1,
|
||||||
|
24,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]?.text.length).toBe(24);
|
||||||
|
expect(result[0]?.text.endsWith("...")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
import { resolveSessionTranscriptPath } from "../config/sessions.js";
|
||||||
|
import { stripEnvelope } from "./chat-sanitize.js";
|
||||||
|
import type { SessionPreviewItem } from "./session-utils.types.js";
|
||||||
|
|
||||||
export function readSessionMessages(
|
export function readSessionMessages(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -189,3 +191,202 @@ export function readLastMessagePreviewFromTranscript(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PREVIEW_READ_SIZES = [64 * 1024, 256 * 1024, 1024 * 1024];
|
||||||
|
const PREVIEW_MAX_LINES = 200;
|
||||||
|
|
||||||
|
type TranscriptContentEntry = {
|
||||||
|
type?: string;
|
||||||
|
text?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TranscriptPreviewMessage = {
|
||||||
|
role?: string;
|
||||||
|
content?: string | TranscriptContentEntry[];
|
||||||
|
text?: string;
|
||||||
|
toolName?: string;
|
||||||
|
tool_name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] {
|
||||||
|
if (isTool) return "tool";
|
||||||
|
switch ((role ?? "").toLowerCase()) {
|
||||||
|
case "user":
|
||||||
|
return "user";
|
||||||
|
case "assistant":
|
||||||
|
return "assistant";
|
||||||
|
case "system":
|
||||||
|
return "system";
|
||||||
|
case "tool":
|
||||||
|
return "tool";
|
||||||
|
default:
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncatePreviewText(text: string, maxChars: number): string {
|
||||||
|
if (maxChars <= 0 || text.length <= maxChars) return text;
|
||||||
|
if (maxChars <= 3) return text.slice(0, maxChars);
|
||||||
|
return `${text.slice(0, maxChars - 3)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
const trimmed = message.content.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
if (Array.isArray(message.content)) {
|
||||||
|
const parts = message.content
|
||||||
|
.map((entry) => (typeof entry?.text === "string" ? entry.text : ""))
|
||||||
|
.filter((text) => text.trim().length > 0);
|
||||||
|
if (parts.length > 0) {
|
||||||
|
return parts.join("\n").trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof message.text === "string") {
|
||||||
|
const trimmed = message.text.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||||
|
if (message.toolName || message.tool_name) return true;
|
||||||
|
if (!Array.isArray(message.content)) return false;
|
||||||
|
return message.content.some((entry) => {
|
||||||
|
if (entry?.name) return true;
|
||||||
|
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
|
||||||
|
return raw === "toolcall" || raw === "tool_call";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||||
|
const names: string[] = [];
|
||||||
|
if (Array.isArray(message.content)) {
|
||||||
|
for (const entry of message.content) {
|
||||||
|
if (typeof entry?.name === "string" && entry.name.trim()) {
|
||||||
|
names.push(entry.name.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toolName = typeof message.toolName === "string" ? message.toolName : message.tool_name;
|
||||||
|
if (typeof toolName === "string" && toolName.trim()) {
|
||||||
|
names.push(toolName.trim());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||||
|
if (!Array.isArray(message.content)) return null;
|
||||||
|
for (const entry of message.content) {
|
||||||
|
const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : "";
|
||||||
|
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") continue;
|
||||||
|
return `[${raw}]`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPreviewItems(
|
||||||
|
messages: TranscriptPreviewMessage[],
|
||||||
|
maxItems: number,
|
||||||
|
maxChars: number,
|
||||||
|
): SessionPreviewItem[] {
|
||||||
|
const items: SessionPreviewItem[] = [];
|
||||||
|
for (const message of messages) {
|
||||||
|
const toolCall = isToolCall(message);
|
||||||
|
const role = normalizeRole(message.role, toolCall);
|
||||||
|
let text = extractPreviewText(message);
|
||||||
|
if (!text) {
|
||||||
|
const toolNames = extractToolNames(message);
|
||||||
|
if (toolNames.length > 0) {
|
||||||
|
const shown = toolNames.slice(0, 2);
|
||||||
|
const overflow = toolNames.length - shown.length;
|
||||||
|
text = `call ${shown.join(", ")}`;
|
||||||
|
if (overflow > 0) text += ` +${overflow}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!text) {
|
||||||
|
text = extractMediaSummary(message);
|
||||||
|
}
|
||||||
|
if (!text) continue;
|
||||||
|
let trimmed = text.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
if (role === "user") {
|
||||||
|
trimmed = stripEnvelope(trimmed);
|
||||||
|
}
|
||||||
|
trimmed = truncatePreviewText(trimmed, maxChars);
|
||||||
|
items.push({ role, text: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length <= maxItems) return items;
|
||||||
|
return items.slice(-maxItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRecentMessagesFromTranscript(
|
||||||
|
filePath: string,
|
||||||
|
maxMessages: number,
|
||||||
|
readBytes: number,
|
||||||
|
): TranscriptPreviewMessage[] {
|
||||||
|
let fd: number | null = null;
|
||||||
|
try {
|
||||||
|
fd = fs.openSync(filePath, "r");
|
||||||
|
const stat = fs.fstatSync(fd);
|
||||||
|
const size = stat.size;
|
||||||
|
if (size === 0) return [];
|
||||||
|
|
||||||
|
const readStart = Math.max(0, size - readBytes);
|
||||||
|
const readLen = Math.min(size, readBytes);
|
||||||
|
const buf = Buffer.alloc(readLen);
|
||||||
|
fs.readSync(fd, buf, 0, readLen, readStart);
|
||||||
|
|
||||||
|
const chunk = buf.toString("utf-8");
|
||||||
|
const lines = chunk.split(/\r?\n/).filter((l) => l.trim());
|
||||||
|
const tailLines = lines.slice(-PREVIEW_MAX_LINES);
|
||||||
|
|
||||||
|
const collected: TranscriptPreviewMessage[] = [];
|
||||||
|
for (let i = tailLines.length - 1; i >= 0; i--) {
|
||||||
|
const line = tailLines[i];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
const msg = parsed?.message as TranscriptPreviewMessage | undefined;
|
||||||
|
if (msg && typeof msg === "object") {
|
||||||
|
collected.push(msg);
|
||||||
|
if (collected.length >= maxMessages) break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return collected.reverse();
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
if (fd !== null) fs.closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readSessionPreviewItemsFromTranscript(
|
||||||
|
sessionId: string,
|
||||||
|
storePath: string | undefined,
|
||||||
|
sessionFile: string | undefined,
|
||||||
|
agentId: string | undefined,
|
||||||
|
maxItems: number,
|
||||||
|
maxChars: number,
|
||||||
|
): SessionPreviewItem[] {
|
||||||
|
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||||
|
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||||
|
if (!filePath) return [];
|
||||||
|
|
||||||
|
const boundedItems = Math.max(1, Math.min(maxItems, 50));
|
||||||
|
const boundedChars = Math.max(20, Math.min(maxChars, 2000));
|
||||||
|
|
||||||
|
for (const readSize of PREVIEW_READ_SIZES) {
|
||||||
|
const messages = readRecentMessagesFromTranscript(filePath, boundedItems, readSize);
|
||||||
|
if (messages.length > 0 || readSize === PREVIEW_READ_SIZES[PREVIEW_READ_SIZES.length - 1]) {
|
||||||
|
return buildPreviewItems(messages, boundedItems, boundedChars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export {
|
|||||||
capArrayByJsonBytes,
|
capArrayByJsonBytes,
|
||||||
readFirstUserMessageFromTranscript,
|
readFirstUserMessageFromTranscript,
|
||||||
readLastMessagePreviewFromTranscript,
|
readLastMessagePreviewFromTranscript,
|
||||||
|
readSessionPreviewItemsFromTranscript,
|
||||||
readSessionMessages,
|
readSessionMessages,
|
||||||
resolveSessionTranscriptCandidates,
|
resolveSessionTranscriptCandidates,
|
||||||
} from "./session-utils.fs.js";
|
} from "./session-utils.fs.js";
|
||||||
@@ -47,6 +48,8 @@ export type {
|
|||||||
GatewaySessionsDefaults,
|
GatewaySessionsDefaults,
|
||||||
SessionsListResult,
|
SessionsListResult,
|
||||||
SessionsPatchResult,
|
SessionsPatchResult,
|
||||||
|
SessionsPreviewEntry,
|
||||||
|
SessionsPreviewResult,
|
||||||
} from "./session-utils.types.js";
|
} from "./session-utils.types.js";
|
||||||
|
|
||||||
const DERIVED_TITLE_MAX_LEN = 60;
|
const DERIVED_TITLE_MAX_LEN = 60;
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ export type GatewayAgentRow = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionPreviewItem = {
|
||||||
|
role: "user" | "assistant" | "tool" | "system" | "other";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionsPreviewEntry = {
|
||||||
|
key: string;
|
||||||
|
status: "ok" | "empty" | "missing" | "error";
|
||||||
|
items: SessionPreviewItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionsPreviewResult = {
|
||||||
|
ts: number;
|
||||||
|
previews: SessionsPreviewEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionsListResult = {
|
export type SessionsListResult = {
|
||||||
ts: number;
|
ts: number;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user