mirror of https://github.com/openclaw/openclaw.git
Merge 2935ac5b82 into 392ddb56e2
This commit is contained in:
commit
9f5d41763d
|
|
@ -1213,6 +1213,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let includearchived: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
|
|
@ -1225,6 +1226,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
includearchived: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
|
|
@ -1236,6 +1238,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.includearchived = includearchived
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
|
|
@ -1249,6 +1252,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case includearchived = "includeArchived"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
|
|
@ -1339,6 +1343,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
public let subagentcontrolscope: AnyCodable?
|
||||
public let sendpolicy: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
public let archivedat: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
|
|
@ -1360,7 +1365,8 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
subagentrole: AnyCodable?,
|
||||
subagentcontrolscope: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
groupactivation: AnyCodable?)
|
||||
groupactivation: AnyCodable?,
|
||||
archivedat: AnyCodable?)
|
||||
{
|
||||
self.key = key
|
||||
self.label = label
|
||||
|
|
@ -1382,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
self.subagentcontrolscope = subagentcontrolscope
|
||||
self.sendpolicy = sendpolicy
|
||||
self.groupactivation = groupactivation
|
||||
self.archivedat = archivedat
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
@ -1405,6 +1412,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
case subagentcontrolscope = "subagentControlScope"
|
||||
case sendpolicy = "sendPolicy"
|
||||
case groupactivation = "groupActivation"
|
||||
case archivedat = "archivedAt"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1213,6 +1213,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
public let activeminutes: Int?
|
||||
public let includeglobal: Bool?
|
||||
public let includeunknown: Bool?
|
||||
public let includearchived: Bool?
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
|
|
@ -1225,6 +1226,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
activeminutes: Int?,
|
||||
includeglobal: Bool?,
|
||||
includeunknown: Bool?,
|
||||
includearchived: Bool?,
|
||||
includederivedtitles: Bool?,
|
||||
includelastmessage: Bool?,
|
||||
label: String?,
|
||||
|
|
@ -1236,6 +1238,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
self.activeminutes = activeminutes
|
||||
self.includeglobal = includeglobal
|
||||
self.includeunknown = includeunknown
|
||||
self.includearchived = includearchived
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
|
|
@ -1249,6 +1252,7 @@ public struct SessionsListParams: Codable, Sendable {
|
|||
case activeminutes = "activeMinutes"
|
||||
case includeglobal = "includeGlobal"
|
||||
case includeunknown = "includeUnknown"
|
||||
case includearchived = "includeArchived"
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
|
|
@ -1339,6 +1343,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
public let subagentcontrolscope: AnyCodable?
|
||||
public let sendpolicy: AnyCodable?
|
||||
public let groupactivation: AnyCodable?
|
||||
public let archivedat: AnyCodable?
|
||||
|
||||
public init(
|
||||
key: String,
|
||||
|
|
@ -1360,7 +1365,8 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
subagentrole: AnyCodable?,
|
||||
subagentcontrolscope: AnyCodable?,
|
||||
sendpolicy: AnyCodable?,
|
||||
groupactivation: AnyCodable?)
|
||||
groupactivation: AnyCodable?,
|
||||
archivedat: AnyCodable?)
|
||||
{
|
||||
self.key = key
|
||||
self.label = label
|
||||
|
|
@ -1382,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
self.subagentcontrolscope = subagentcontrolscope
|
||||
self.sendpolicy = sendpolicy
|
||||
self.groupactivation = groupactivation
|
||||
self.archivedat = archivedat
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
|
|
@ -1405,6 +1412,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
|||
case subagentcontrolscope = "subagentControlScope"
|
||||
case sendpolicy = "sendPolicy"
|
||||
case groupactivation = "groupActivation"
|
||||
case archivedat = "archivedAt"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -99,6 +99,7 @@ Text + native (when enabled):
|
|||
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
|
||||
- `/dock-discord` (alias: `/dock_discord`) (switch replies to Discord)
|
||||
- `/dock-slack` (alias: `/dock_slack`) (switch replies to Slack)
|
||||
- `/set_topic_name <name>` (Telegram topics only)
|
||||
- `/activation mention|always` (groups only)
|
||||
- `/send on|off|inherit` (owner-only)
|
||||
- `/reset` or `/new [model]` (optional model hint; remainder is passed through)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { resolveConfiguredAcpRoute } from "../../../src/acp/persistent-bindings.route.js";
|
||||
import { resolveDefaultAgentId } from "../../../src/agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import { getSessionBindingService } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
buildAgentSessionKey,
|
||||
deriveLastRoutePolicy,
|
||||
pickFirstExistingAgentId,
|
||||
resolveAgentRoute,
|
||||
} from "../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
|
|
@ -58,7 +60,12 @@ export function resolveTelegramConversationRoute(params: {
|
|||
if (rawTopicAgentId) {
|
||||
// Preserve the configured topic agent ID so topic-bound sessions stay stable
|
||||
// even when that agent is not present in the current config snapshot.
|
||||
const topicAgentId = sanitizeAgentId(rawTopicAgentId);
|
||||
const normalizedRawTopicAgentId = sanitizeAgentId(rawTopicAgentId);
|
||||
const defaultAgentId = sanitizeAgentId(resolveDefaultAgentId(params.cfg));
|
||||
let topicAgentId = pickFirstExistingAgentId(params.cfg, rawTopicAgentId);
|
||||
if (topicAgentId === defaultAgentId && normalizedRawTopicAgentId !== defaultAgentId) {
|
||||
topicAgentId = normalizedRawTopicAgentId;
|
||||
}
|
||||
route = {
|
||||
...route,
|
||||
agentId: topicAgentId,
|
||||
|
|
|
|||
|
|
@ -382,6 +382,21 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||
textAlias: "/unfocus",
|
||||
category: "management",
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "set_topic_name",
|
||||
nativeName: "set_topic_name",
|
||||
description: "Set the display label for the current Telegram topic.",
|
||||
textAlias: "/set_topic_name",
|
||||
category: "management",
|
||||
args: [
|
||||
{
|
||||
name: "name",
|
||||
description: "Topic label",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "agents",
|
||||
nativeName: "agents",
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
handleUsageCommand,
|
||||
} from "./commands-session.js";
|
||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||
import { handleSetTopicNameCommand } from "./commands-topic-name.js";
|
||||
import { handleTtsCommands } from "./commands-tts.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
|
|
@ -184,6 +185,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
|||
handleSessionCommand,
|
||||
handleRestartCommand,
|
||||
handleTtsCommands,
|
||||
handleSetTopicNameCommand,
|
||||
handleHelpCommand,
|
||||
handleCommandsListCommand,
|
||||
handleStatusCommand,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
import { callGateway } from "../../gateway/call.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { isTelegramSurface } from "./channel-context.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
const COMMAND_REGEX = /^\/set_topic_name(?:\s|$)/i;
|
||||
const MAX_NAME_LEN = 64;
|
||||
const MAX_LABEL_LEN = 64;
|
||||
|
||||
function parseTopicNameCommand(
|
||||
raw: string,
|
||||
): { ok: true; name: string } | { ok: false; error: string } | null {
|
||||
const trimmed = raw.trim();
|
||||
const match = trimmed.match(COMMAND_REGEX);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice(match[0].length).trim();
|
||||
const name = rest.replace(/·/g, " ").slice(0, MAX_NAME_LEN).trim();
|
||||
if (!name) {
|
||||
return { ok: false, error: "Usage: /set_topic_name <name>" };
|
||||
}
|
||||
return { ok: true, name };
|
||||
}
|
||||
|
||||
function resolveConversationLabel(params: Parameters<CommandHandler>[0]): string {
|
||||
return (
|
||||
(typeof params.ctx.ConversationLabel === "string" ? params.ctx.ConversationLabel.trim() : "") ||
|
||||
(typeof params.ctx.GroupSubject === "string" ? params.ctx.GroupSubject.trim() : "")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseLabel(raw: string): string {
|
||||
return raw.replace(/^telegram\s*·\s*/i, "").trim();
|
||||
}
|
||||
|
||||
function buildTopicLabel(baseLabel: string, name: string): string {
|
||||
const prefix = "telegram";
|
||||
const separator = " · ";
|
||||
let normalizedBase = baseLabel ? normalizeBaseLabel(baseLabel) : "";
|
||||
|
||||
const maxNameWithoutBase = Math.max(1, MAX_LABEL_LEN - (prefix + separator).length);
|
||||
let nextName = name.slice(0, maxNameWithoutBase).trim();
|
||||
|
||||
if (normalizedBase) {
|
||||
const maxBaseLen = MAX_LABEL_LEN - (prefix + separator + separator + nextName).length;
|
||||
if (maxBaseLen > 0) {
|
||||
normalizedBase = normalizedBase.slice(0, maxBaseLen).trim();
|
||||
} else {
|
||||
normalizedBase = "";
|
||||
}
|
||||
}
|
||||
|
||||
const maxNameLen = Math.max(
|
||||
1,
|
||||
MAX_LABEL_LEN -
|
||||
(prefix + separator + (normalizedBase ? normalizedBase + separator : "")).length,
|
||||
);
|
||||
nextName = name.slice(0, maxNameLen).trim();
|
||||
|
||||
return normalizedBase
|
||||
? `${prefix}${separator}${normalizedBase}${separator}${nextName}`
|
||||
: `${prefix}${separator}${nextName}`;
|
||||
}
|
||||
|
||||
export const handleSetTopicNameCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseTopicNameCommand(params.command.commandBodyNormalized);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
if (!params.command.isAuthorizedSender) {
|
||||
logVerbose(
|
||||
`Ignoring /set_topic_name from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
|
||||
);
|
||||
return { shouldContinue: false };
|
||||
}
|
||||
if (!isTelegramSurface(params)) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ /set_topic_name only works for Telegram topics." },
|
||||
};
|
||||
}
|
||||
if (!parsed.ok) {
|
||||
return { shouldContinue: false, reply: { text: parsed.error } };
|
||||
}
|
||||
const threadId = params.ctx.MessageThreadId;
|
||||
if (threadId == null || `${threadId}`.trim() === "") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ /set_topic_name only works inside a Telegram topic." },
|
||||
};
|
||||
}
|
||||
// Telegram topics are thread-scoped; sessionKey already includes the thread context.
|
||||
// threadId is only used to enforce topic usage, not to disambiguate sessions.patch.
|
||||
if (!params.sessionKey) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "⚙️ Could not resolve the current session key." },
|
||||
};
|
||||
}
|
||||
const baseLabel = resolveConversationLabel(params);
|
||||
const label = buildTopicLabel(baseLabel, parsed.name);
|
||||
|
||||
try {
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: {
|
||||
key: params.sessionKey,
|
||||
label,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`/set_topic_name gateway error: ${String(err)}`);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: "❌ Failed to set topic name. Please try again later." },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ Topic label set to ${label}.` },
|
||||
};
|
||||
};
|
||||
|
|
@ -439,7 +439,11 @@ export async function initSessionState(params: {
|
|||
lastTo,
|
||||
lastAccountId,
|
||||
lastThreadId,
|
||||
archivedAt: baseEntry?.archivedAt,
|
||||
};
|
||||
if (typeof sessionEntry.archivedAt === "number") {
|
||||
sessionEntry.archivedAt = undefined;
|
||||
}
|
||||
const metaPatch = deriveSessionMetaPatch({
|
||||
ctx: sessionCtxForState,
|
||||
sessionKey,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,7 @@ export type SessionEntry = {
|
|||
claudeCliSessionId?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
archivedAt?: number;
|
||||
channel?: string;
|
||||
groupId?: string;
|
||||
subject?: string;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const SessionsListParamsSchema = Type.Object(
|
|||
activeMinutes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
includeGlobal: Type.Optional(Type.Boolean()),
|
||||
includeUnknown: Type.Optional(Type.Boolean()),
|
||||
includeArchived: Type.Optional(Type.Boolean()),
|
||||
/**
|
||||
* Read first 8KB of each session transcript to derive title from first user message.
|
||||
* Performs a file read per session - use `limit` to bound result set on large stores.
|
||||
|
|
@ -86,6 +87,7 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||
groupActivation: Type.Optional(
|
||||
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
|
||||
),
|
||||
archivedAt: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
updateSessionStore,
|
||||
} from "../../config/sessions.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
import { writeSessionArchiveSummary } from "../../sessions/archive-summary.js";
|
||||
import { GATEWAY_CLIENT_IDS } from "../protocol/client-info.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
|
|
@ -212,6 +213,23 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
|||
respond(false, undefined, applied.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasArchived = typeof applied.previous?.archivedAt === "number";
|
||||
const isArchived = typeof applied.entry.archivedAt === "number";
|
||||
if (!wasArchived && isArchived) {
|
||||
setImmediate(() => {
|
||||
void writeSessionArchiveSummary({
|
||||
cfg,
|
||||
key: target.canonicalKey ?? key,
|
||||
entry: applied.entry,
|
||||
storePath,
|
||||
archivedAt: applied.entry.archivedAt ?? undefined,
|
||||
}).catch((err) => {
|
||||
console.warn(`sessions.patch archive summary failed: ${String(err)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = parseAgentSessionKey(target.canonicalKey ?? key);
|
||||
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const resolved = resolveSessionModelRef(cfg, applied.entry, agentId);
|
||||
|
|
|
|||
|
|
@ -854,6 +854,7 @@ export function listSessionsFromStore(params: {
|
|||
|
||||
const includeGlobal = opts.includeGlobal === true;
|
||||
const includeUnknown = opts.includeUnknown === true;
|
||||
const includeArchived = opts.includeArchived === true;
|
||||
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
||||
const includeLastMessage = opts.includeLastMessage === true;
|
||||
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
||||
|
|
@ -889,6 +890,9 @@ export function listSessionsFromStore(params: {
|
|||
return true;
|
||||
})
|
||||
.filter(([key, entry]) => {
|
||||
if (!includeArchived && typeof entry?.archivedAt === "number") {
|
||||
return false;
|
||||
}
|
||||
if (!spawnedBy) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -971,6 +975,7 @@ export function listSessionsFromStore(params: {
|
|||
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
||||
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
|
||||
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
|
||||
archivedAt: entry?.archivedAt ?? null,
|
||||
};
|
||||
})
|
||||
.toSorted((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export type GatewaySessionRow = {
|
|||
lastChannel?: SessionEntry["lastChannel"];
|
||||
lastTo?: string;
|
||||
lastAccountId?: string;
|
||||
archivedAt?: number | null;
|
||||
};
|
||||
|
||||
export type GatewayAgentRow = SharedGatewayAgentRow;
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ export async function applySessionsPatchToStore(params: {
|
|||
storeKey: string;
|
||||
patch: SessionsPatchParams;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
}): Promise<{ ok: true; entry: SessionEntry } | { ok: false; error: ErrorShape }> {
|
||||
}): Promise<
|
||||
{ ok: true; entry: SessionEntry; previous?: SessionEntry } | { ok: false; error: ErrorShape }
|
||||
> {
|
||||
const { cfg, store, storeKey, patch } = params;
|
||||
const now = Date.now();
|
||||
const parsedAgent = parseAgentSessionKey(storeKey);
|
||||
|
|
@ -235,6 +237,19 @@ export async function applySessionsPatchToStore(params: {
|
|||
}
|
||||
}
|
||||
|
||||
if ("archivedAt" in patch) {
|
||||
const raw = patch.archivedAt;
|
||||
if (raw === null) {
|
||||
delete next.archivedAt;
|
||||
} else if (raw !== undefined) {
|
||||
const numeric = Number(raw);
|
||||
if (!Number.isFinite(numeric) || numeric < 0) {
|
||||
return invalid("invalid archivedAt (use epoch ms >= 0)");
|
||||
}
|
||||
next.archivedAt = Math.floor(numeric);
|
||||
}
|
||||
}
|
||||
|
||||
if ("thinkingLevel" in patch) {
|
||||
const raw = patch.thinkingLevel;
|
||||
if (raw === null) {
|
||||
|
|
@ -458,5 +473,5 @@ export async function applySessionsPatchToStore(params: {
|
|||
}
|
||||
|
||||
store[storeKey] = next;
|
||||
return { ok: true, entry: next };
|
||||
return { ok: true, entry: next, previous: existing };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export async function resolveSessionKeyFromResolveParams(params: {
|
|||
opts: {
|
||||
includeGlobal: p.includeGlobal === true,
|
||||
includeUnknown: p.includeUnknown === true,
|
||||
includeArchived: true,
|
||||
spawnedBy: p.spawnedBy,
|
||||
agentId: p.agentId,
|
||||
search: sessionId,
|
||||
|
|
@ -119,6 +120,7 @@ export async function resolveSessionKeyFromResolveParams(params: {
|
|||
opts: {
|
||||
includeGlobal: p.includeGlobal === true,
|
||||
includeUnknown: p.includeUnknown === true,
|
||||
includeArchived: true,
|
||||
label: parsedLabel.label,
|
||||
agentId: p.agentId,
|
||||
spawnedBy: p.spawnedBy,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,134 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { readSessionMessages } from "../gateway/session-utils.fs.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
|
||||
const log = createSubsystemLogger("sessions-archive");
|
||||
|
||||
function extractMessageText(message: unknown): string {
|
||||
if (!message || typeof message !== "object") {
|
||||
return "";
|
||||
}
|
||||
const msg = message as { content?: unknown; text?: unknown; message?: unknown };
|
||||
if (typeof msg.text === "string") {
|
||||
return msg.text;
|
||||
}
|
||||
const content = msg.content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
const parts = content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") {
|
||||
return "";
|
||||
}
|
||||
const entry = part as { type?: string; text?: string };
|
||||
if (entry.type === "text" && typeof entry.text === "string") {
|
||||
return entry.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.join(" ").trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function truncate(text: string, max = 200): string {
|
||||
if (text.length <= max) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, max - 1).trim()}…`;
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function resolveUniquePath(dir: string, baseName: string): Promise<string> {
|
||||
let candidate = path.join(dir, baseName);
|
||||
if (!(await fileExists(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
candidate = path.join(dir, baseName.replace(/\.md$/i, `-${i}.md`));
|
||||
if (!(await fileExists(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return path.join(dir, `${Date.now()}-${baseName}`);
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeSessionArchiveSummary(params: {
|
||||
cfg: OpenClawConfig;
|
||||
key: string;
|
||||
entry: SessionEntry;
|
||||
storePath?: string;
|
||||
archivedAt?: number | null;
|
||||
}): Promise<{ ok: true; path: string } | { ok: false; reason: string }> {
|
||||
const { cfg, key, entry, storePath } = params;
|
||||
const archivedAt = typeof params.archivedAt === "number" ? params.archivedAt : Date.now();
|
||||
const sessionId = entry.sessionId;
|
||||
if (!sessionId) {
|
||||
return { ok: false, reason: "missing sessionId" };
|
||||
}
|
||||
|
||||
const messages = readSessionMessages(sessionId, storePath, entry.sessionFile);
|
||||
if (!messages.length) {
|
||||
return { ok: false, reason: "no transcript" };
|
||||
}
|
||||
|
||||
const userMessages = messages
|
||||
.filter((msg) => (msg as { role?: string })?.role === "user")
|
||||
.map((msg) => extractMessageText(msg))
|
||||
.filter((text) => text);
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((msg) => (msg as { role?: string })?.role === "assistant")
|
||||
.map((msg) => extractMessageText(msg))
|
||||
.filter((text) => text);
|
||||
|
||||
const firstUser = userMessages.slice(0, 3).map((text) => truncate(text));
|
||||
const lastUser = userMessages.slice(-3).map((text) => truncate(text));
|
||||
|
||||
const label = entry.label?.trim() || entry.displayName?.trim() || entry.origin?.label?.trim();
|
||||
const displayLabel = label || key;
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
const agentId = normalizeAgentId(parsed?.agentId ?? resolveDefaultAgentId(cfg));
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const date = new Date(archivedAt).toISOString().slice(0, 10);
|
||||
const dir = path.join(workspaceDir, "memory");
|
||||
|
||||
const baseSlug = slugify(displayLabel) || "session";
|
||||
const keySlug = slugify(key) || "session";
|
||||
const baseName = `${date}-${baseSlug}--${keySlug}.md`;
|
||||
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const filePath = await resolveUniquePath(dir, baseName);
|
||||
|
||||
const summary = `# Session Summary\n\n- Session: ${displayLabel}\n- Key: \`${key}\`\n- Archived at: ${new Date(archivedAt).toISOString()}\n- Messages: ${messages.length} total (${userMessages.length} user, ${assistantMessages.length} assistant)\n\n## First user messages\n${
|
||||
firstUser.length ? firstUser.map((line) => `- ${line}`).join("\n") : "- (none)"
|
||||
}\n\n## Recent user messages\n${lastUser.length ? lastUser.map((line) => `- ${line}`).join("\n") : "- (none)"}\n`;
|
||||
|
||||
await fs.writeFile(filePath, summary, "utf-8");
|
||||
log.info(`wrote archive summary to ${filePath}`);
|
||||
return { ok: true, path: filePath };
|
||||
}
|
||||
|
|
@ -726,6 +726,7 @@ export function renderApp(state: AppViewState) {
|
|||
limit: state.sessionsFilterLimit,
|
||||
includeGlobal: state.sessionsIncludeGlobal,
|
||||
includeUnknown: state.sessionsIncludeUnknown,
|
||||
includeArchived: state.sessionsIncludeArchived,
|
||||
basePath: state.basePath,
|
||||
searchQuery: state.sessionsSearchQuery,
|
||||
sortColumn: state.sessionsSortColumn,
|
||||
|
|
@ -738,6 +739,18 @@ export function renderApp(state: AppViewState) {
|
|||
state.sessionsFilterLimit = next.limit;
|
||||
state.sessionsIncludeGlobal = next.includeGlobal;
|
||||
state.sessionsIncludeUnknown = next.includeUnknown;
|
||||
state.sessionsIncludeArchived = next.includeArchived;
|
||||
const activeMinutes = Number(next.activeMinutes);
|
||||
const limit = Number(next.limit);
|
||||
// Reload immediately so filter changes take effect without waiting for polling.
|
||||
// Empty/invalid inputs fall back to 0 to disable that filter.
|
||||
void loadSessions(state, {
|
||||
activeMinutes: Number.isFinite(activeMinutes) ? activeMinutes : 0,
|
||||
limit: Number.isFinite(limit) ? limit : 0,
|
||||
includeGlobal: next.includeGlobal,
|
||||
includeUnknown: next.includeUnknown,
|
||||
includeArchived: next.includeArchived,
|
||||
});
|
||||
},
|
||||
onSearchChange: (q) => {
|
||||
state.sessionsSearchQuery = q;
|
||||
|
|
|
|||
|
|
@ -184,6 +184,7 @@ export type AppViewState = {
|
|||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
sessionsIncludeArchived: boolean;
|
||||
sessionsHideCron: boolean;
|
||||
sessionsSearchQuery: string;
|
||||
sessionsSortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
|
|
|
|||
|
|
@ -282,6 +282,7 @@ export class OpenClawApp extends LitElement {
|
|||
@state() sessionsFilterLimit = "120";
|
||||
@state() sessionsIncludeGlobal = true;
|
||||
@state() sessionsIncludeUnknown = false;
|
||||
@state() sessionsIncludeArchived = false;
|
||||
@state() sessionsHideCron = true;
|
||||
@state() sessionsSearchQuery = "";
|
||||
@state() sessionsSortColumn: "key" | "kind" | "updated" | "tokens" = "updated";
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function createState(request: RequestFn, overrides: Partial<SessionsState> = {})
|
|||
sessionsFilterLimit: "0",
|
||||
sessionsIncludeGlobal: true,
|
||||
sessionsIncludeUnknown: true,
|
||||
sessionsIncludeArchived: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type SessionsState = {
|
|||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
sessionsIncludeArchived: boolean;
|
||||
};
|
||||
|
||||
export async function loadSessions(
|
||||
|
|
@ -21,6 +22,7 @@ export async function loadSessions(
|
|||
limit?: number;
|
||||
includeGlobal?: boolean;
|
||||
includeUnknown?: boolean;
|
||||
includeArchived?: boolean;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
|
|
@ -34,12 +36,16 @@ export async function loadSessions(
|
|||
try {
|
||||
const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal;
|
||||
const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown;
|
||||
const includeArchived = overrides?.includeArchived ?? state.sessionsIncludeArchived;
|
||||
const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0);
|
||||
const limit = overrides?.limit ?? toNumber(state.sessionsFilterLimit, 0);
|
||||
const params: Record<string, unknown> = {
|
||||
includeGlobal,
|
||||
includeUnknown,
|
||||
};
|
||||
if (includeArchived) {
|
||||
params.includeArchived = true;
|
||||
}
|
||||
if (activeMinutes > 0) {
|
||||
params.activeMinutes = activeMinutes;
|
||||
}
|
||||
|
|
@ -66,6 +72,7 @@ export async function patchSession(
|
|||
fastMode?: boolean | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
archivedAt?: number | null;
|
||||
},
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
|
|
@ -87,6 +94,9 @@ export async function patchSession(
|
|||
if ("reasoningLevel" in patch) {
|
||||
params.reasoningLevel = patch.reasoningLevel;
|
||||
}
|
||||
if ("archivedAt" in patch) {
|
||||
params.archivedAt = patch.archivedAt;
|
||||
}
|
||||
try {
|
||||
await state.client.request("sessions.patch", params);
|
||||
await loadSessions(state);
|
||||
|
|
|
|||
|
|
@ -389,6 +389,7 @@ export type GatewaySessionRow = {
|
|||
model?: string;
|
||||
modelProvider?: string;
|
||||
contextTokens?: number;
|
||||
archivedAt?: number | null;
|
||||
};
|
||||
|
||||
export type SessionsListResult = SessionsListResultBase<GatewaySessionsDefaults, GatewaySessionRow>;
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ function buildProps(result: SessionsListResult): SessionsProps {
|
|||
limit: "120",
|
||||
includeGlobal: false,
|
||||
includeUnknown: false,
|
||||
includeArchived: false,
|
||||
basePath: "",
|
||||
searchQuery: "",
|
||||
sortColumn: "updated",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export type SessionsProps = {
|
|||
limit: string;
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
includeArchived: boolean;
|
||||
basePath: string;
|
||||
searchQuery: string;
|
||||
sortColumn: "key" | "kind" | "updated" | "tokens";
|
||||
|
|
@ -25,6 +26,7 @@ export type SessionsProps = {
|
|||
limit: string;
|
||||
includeGlobal: boolean;
|
||||
includeUnknown: boolean;
|
||||
includeArchived: boolean;
|
||||
}) => void;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSortChange: (column: "key" | "kind" | "updated" | "tokens", dir: "asc" | "desc") => void;
|
||||
|
|
@ -40,6 +42,7 @@ export type SessionsProps = {
|
|||
fastMode?: boolean | null;
|
||||
verboseLevel?: string | null;
|
||||
reasoningLevel?: string | null;
|
||||
archivedAt?: number | null;
|
||||
},
|
||||
) => void;
|
||||
onDelete: (key: string) => void;
|
||||
|
|
@ -179,7 +182,12 @@ function paginateRows<T>(rows: T[], page: number, pageSize: number): T[] {
|
|||
|
||||
export function renderSessions(props: SessionsProps) {
|
||||
const rawRows = props.result?.sessions ?? [];
|
||||
const filtered = filterRows(rawRows, props.searchQuery);
|
||||
// Client-side guard: hides archived rows immediately on toggle while the
|
||||
// server request (triggered by onFiltersChange) is in-flight.
|
||||
const visibleRows = props.includeArchived
|
||||
? rawRows
|
||||
: rawRows.filter((row) => typeof row.archivedAt !== "number");
|
||||
const filtered = filterRows(visibleRows, props.searchQuery);
|
||||
const sorted = sortRows(filtered, props.sortColumn, props.sortDir);
|
||||
const totalRows = sorted.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / props.pageSize));
|
||||
|
|
@ -237,6 +245,7 @@ export function renderSessions(props: SessionsProps) {
|
|||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
includeArchived: props.includeArchived,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -251,6 +260,7 @@ export function renderSessions(props: SessionsProps) {
|
|||
limit: (e.target as HTMLInputElement).value,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
includeArchived: props.includeArchived,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -264,6 +274,7 @@ export function renderSessions(props: SessionsProps) {
|
|||
limit: props.limit,
|
||||
includeGlobal: (e.target as HTMLInputElement).checked,
|
||||
includeUnknown: props.includeUnknown,
|
||||
includeArchived: props.includeArchived,
|
||||
})}
|
||||
/>
|
||||
<span>Global</span>
|
||||
|
|
@ -278,10 +289,26 @@ export function renderSessions(props: SessionsProps) {
|
|||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: (e.target as HTMLInputElement).checked,
|
||||
includeArchived: props.includeArchived,
|
||||
})}
|
||||
/>
|
||||
<span>Unknown</span>
|
||||
</label>
|
||||
<label class="field-inline checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${props.includeArchived}
|
||||
@change=${(e: Event) =>
|
||||
props.onFiltersChange({
|
||||
activeMinutes: props.activeMinutes,
|
||||
limit: props.limit,
|
||||
includeGlobal: props.includeGlobal,
|
||||
includeUnknown: props.includeUnknown,
|
||||
includeArchived: (e.target as HTMLInputElement).checked,
|
||||
})}
|
||||
/>
|
||||
<span>Archived</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
${
|
||||
|
|
@ -425,6 +452,7 @@ function renderRow(
|
|||
: row.kind === "global"
|
||||
? "data-table-badge--global"
|
||||
: "data-table-badge--unknown";
|
||||
const isArchived = typeof row.archivedAt === "number";
|
||||
|
||||
return html`
|
||||
<tr>
|
||||
|
|
@ -436,6 +464,13 @@ function renderRow(
|
|||
? html`<span class="muted session-key-display-name">${displayName}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
isArchived
|
||||
? html`
|
||||
<span class="data-table-badge" style="margin-left: 6px">Archived</span>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
|
|
@ -555,6 +590,15 @@ function renderRow(
|
|||
`
|
||||
: nothing
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
@click=${() => {
|
||||
onActionsOpenChange(null);
|
||||
onPatch(row.key, { archivedAt: isArchived ? null : Date.now() });
|
||||
}}
|
||||
>
|
||||
${isArchived ? "Unarchive" : "Archive"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
|
|
|
|||
Loading…
Reference in New Issue