Files
openclaw/src/auto-reply/reply/session-updates.ts

289 lines
9.0 KiB
TypeScript
Raw Normal View History

2026-01-04 05:47:21 +01:00
import crypto from "node:crypto";
2026-01-22 04:15:39 +00:00
import { resolveUserTimezone } from "../../agents/date-time.js";
2026-01-04 05:47:21 +01:00
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import type { OpenClawConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { buildChannelSummary } from "../../infra/channel-summary.js";
import {
resolveTimezone,
formatUtcTimestamp,
formatZonedTimestamp,
} from "../../infra/format-time/format-datetime.ts";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
2026-01-13 06:27:55 +00:00
import { drainSystemEventEntries } from "../../infra/system-events.js";
2026-01-04 05:47:21 +01:00
export async function prependSystemEvents(params: {
2026-01-30 03:15:10 +01:00
cfg: OpenClawConfig;
2026-01-04 22:11:04 +01:00
sessionKey: string;
2026-01-04 05:47:21 +01:00
isMainSession: boolean;
isNewSession: boolean;
prefixedBodyBase: string;
}): Promise<string> {
const compactSystemEvent = (line: string): string | null => {
const trimmed = line.trim();
if (!trimmed) {
return null;
}
2026-01-04 05:47:21 +01:00
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) {
return null;
}
// Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat"
// The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this
if (lower.startsWith("read heartbeat.md")) {
return null;
}
// Also filter heartbeat poll/wake noise
if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) {
return null;
}
2026-01-04 05:47:21 +01:00
if (trimmed.startsWith("Node:")) {
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}
return trimmed;
};
2026-01-30 03:15:10 +01:00
const resolveSystemEventTimezone = (cfg: OpenClawConfig) => {
2026-01-22 04:15:39 +00:00
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
if (!raw) {
return { mode: "local" as const };
}
2026-01-22 04:15:39 +00:00
const lowered = raw.toLowerCase();
if (lowered === "utc" || lowered === "gmt") {
return { mode: "utc" as const };
}
if (lowered === "local" || lowered === "host") {
return { mode: "local" as const };
}
2026-01-22 04:15:39 +00:00
if (lowered === "user") {
return {
mode: "iana" as const,
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
};
}
const explicit = resolveTimezone(raw);
2026-01-22 04:15:39 +00:00
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
};
2026-01-30 03:15:10 +01:00
const formatSystemEventTimestamp = (ts: number, cfg: OpenClawConfig) => {
2026-01-22 04:15:39 +00:00
const date = new Date(ts);
if (Number.isNaN(date.getTime())) {
return "unknown-time";
}
2026-01-22 04:15:39 +00:00
const zone = resolveSystemEventTimezone(cfg);
if (zone.mode === "utc") {
return formatUtcTimestamp(date, { displaySeconds: true });
}
if (zone.mode === "local") {
return formatZonedTimestamp(date, { displaySeconds: true }) ?? "unknown-time";
}
return (
formatZonedTimestamp(date, { timeZone: zone.timeZone, displaySeconds: true }) ??
"unknown-time"
);
2026-01-22 04:15:39 +00:00
};
2026-01-04 05:47:21 +01:00
const systemLines: string[] = [];
const queued = drainSystemEventEntries(params.sessionKey);
2026-01-04 05:47:21 +01:00
systemLines.push(
...queued
.map((event) => {
const compacted = compactSystemEvent(event.text);
if (!compacted) {
return null;
}
2026-01-22 04:15:39 +00:00
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
})
.filter((v): v is string => Boolean(v)),
2026-01-04 05:47:21 +01:00
);
2026-01-04 22:11:04 +01:00
if (params.isMainSession && params.isNewSession) {
const summary = await buildChannelSummary(params.cfg);
if (summary.length > 0) {
systemLines.unshift(...summary);
}
}
if (systemLines.length === 0) {
return params.prefixedBodyBase;
2026-01-04 05:47:21 +01:00
}
const block = systemLines.map((l) => `System: ${l}`).join("\n");
return `${block}\n\n${params.prefixedBodyBase}`;
}
export async function ensureSkillSnapshot(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
sessionId?: string;
isFirstTurnInSession: boolean;
workspaceDir: string;
2026-01-30 03:15:10 +01:00
cfg: OpenClawConfig;
/** If provided, only load skills with these names (for per-channel skill filtering) */
skillFilter?: string[];
2026-01-04 05:47:21 +01:00
}): Promise<{
sessionEntry?: SessionEntry;
skillsSnapshot?: SessionEntry["skillsSnapshot"];
systemSent: boolean;
}> {
if (process.env.OPENCLAW_TEST_FAST === "1") {
// In fast unit-test runs we skip filesystem scanning, watchers, and session-store writes.
// Dedicated skills tests cover snapshot generation behavior.
return {
sessionEntry: params.sessionEntry,
skillsSnapshot: params.sessionEntry?.skillsSnapshot,
systemSent: params.sessionEntry?.systemSent ?? false,
};
}
2026-01-04 05:47:21 +01:00
const {
sessionEntry,
sessionStore,
sessionKey,
storePath,
sessionId,
isFirstTurnInSession,
workspaceDir,
cfg,
skillFilter,
2026-01-04 05:47:21 +01:00
} = params;
let nextEntry = sessionEntry;
let systemSent = sessionEntry?.systemSent ?? false;
const remoteEligibility = getRemoteSkillEligibility();
const snapshotVersion = getSkillsSnapshotVersion(workspaceDir);
ensureSkillsWatcher({ workspaceDir, config: cfg });
const shouldRefreshSnapshot =
snapshotVersion > 0 && (nextEntry?.skillsSnapshot?.version ?? 0) < snapshotVersion;
2026-01-04 05:47:21 +01:00
if (isFirstTurnInSession && sessionStore && sessionKey) {
const current = nextEntry ??
sessionStore[sessionKey] ?? {
sessionId: sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
};
const skillSnapshot =
isFirstTurnInSession || !current.skillsSnapshot || shouldRefreshSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
eligibility: { remote: remoteEligibility },
snapshotVersion,
})
2026-01-04 05:47:21 +01:00
: current.skillsSnapshot;
nextEntry = {
...current,
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
systemSent: true,
skillsSnapshot: skillSnapshot,
};
feat(sessions): expose label in sessions.list and support label lookup in sessions_send - Add `label` field to session entries and expose it in `sessions.list` - Display label column in the web UI sessions table - Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey - `sessions.patch`: Accept and store `label` field - `sessions.list`: Return `label` in session entries - `sessions_spawn`: Pass label through to registry and announce flow - `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided - `agent` method: Accept `label` and `spawnedBy` params (stored in session entry) - Add `label` column to sessions table in web UI - Changed session store writes to merge with existing entry (`{ ...existing, ...new }`) to preserve fields like `label` that might be set separately We attempted to implement label persistence "properly" by passing the label through the `agent` call and storing it during session initialization. However, the auto-reply flow has multiple write points that overwrite the session entry, and making all of them merge-aware proved unreliable. The working solution patches the label in the `finally` block of `runSubagentAnnounceFlow`, after all other session writes complete. This is a workaround but robust - the patch happens at the very end, just before potential cleanup. A future refactor could make session writes consistently merge-based, which would allow the cleaner approach of setting label at spawn time. ```typescript // Spawn with label sessions_spawn({ task: "...", label: "my-worker" }) // Later, find by label sessions_send({ label: "my-worker", message: "continue..." }) // Or use sessions_list to see labels sessions_list() // includes label field in response ```
2026-01-08 23:17:08 +00:00
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
2026-01-04 05:47:21 +01:00
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
});
2026-01-04 05:47:21 +01:00
}
systemSent = true;
}
const skillsSnapshot = shouldRefreshSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
eligibility: { remote: remoteEligibility },
snapshotVersion,
})
: (nextEntry?.skillsSnapshot ??
(isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
eligibility: { remote: remoteEligibility },
snapshotVersion,
})));
2026-01-04 05:47:21 +01:00
if (
skillsSnapshot &&
sessionStore &&
sessionKey &&
!isFirstTurnInSession &&
(!nextEntry?.skillsSnapshot || shouldRefreshSnapshot)
2026-01-04 05:47:21 +01:00
) {
const current = nextEntry ?? {
sessionId: sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
};
nextEntry = {
...current,
sessionId: sessionId ?? current.sessionId ?? crypto.randomUUID(),
updatedAt: Date.now(),
skillsSnapshot,
};
feat(sessions): expose label in sessions.list and support label lookup in sessions_send - Add `label` field to session entries and expose it in `sessions.list` - Display label column in the web UI sessions table - Support `label` parameter in `sessions_send` for lookup by label instead of sessionKey - `sessions.patch`: Accept and store `label` field - `sessions.list`: Return `label` in session entries - `sessions_spawn`: Pass label through to registry and announce flow - `sessions_send`: Accept optional `label` param, lookup session by label if sessionKey not provided - `agent` method: Accept `label` and `spawnedBy` params (stored in session entry) - Add `label` column to sessions table in web UI - Changed session store writes to merge with existing entry (`{ ...existing, ...new }`) to preserve fields like `label` that might be set separately We attempted to implement label persistence "properly" by passing the label through the `agent` call and storing it during session initialization. However, the auto-reply flow has multiple write points that overwrite the session entry, and making all of them merge-aware proved unreliable. The working solution patches the label in the `finally` block of `runSubagentAnnounceFlow`, after all other session writes complete. This is a workaround but robust - the patch happens at the very end, just before potential cleanup. A future refactor could make session writes consistently merge-based, which would allow the cleaner approach of setting label at spawn time. ```typescript // Spawn with label sessions_spawn({ task: "...", label: "my-worker" }) // Later, find by label sessions_send({ label: "my-worker", message: "continue..." }) // Or use sessions_list to see labels sessions_list() // includes label field in response ```
2026-01-08 23:17:08 +00:00
sessionStore[sessionKey] = { ...sessionStore[sessionKey], ...nextEntry };
2026-01-04 05:47:21 +01:00
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = { ...store[sessionKey], ...nextEntry };
});
2026-01-04 05:47:21 +01:00
}
}
return { sessionEntry: nextEntry, skillsSnapshot, systemSent };
}
export async function incrementCompactionCount(params: {
sessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
now?: number;
/** Token count after compaction - if provided, updates session token counts */
tokensAfter?: number;
}): Promise<number | undefined> {
2026-01-22 17:47:52 +00:00
const {
sessionEntry,
sessionStore,
sessionKey,
storePath,
now = Date.now(),
tokensAfter,
} = params;
if (!sessionStore || !sessionKey) {
return undefined;
}
const entry = sessionStore[sessionKey] ?? sessionEntry;
if (!entry) {
return undefined;
}
const nextCount = (entry.compactionCount ?? 0) + 1;
// Build update payload with compaction count and optionally updated token counts
const updates: Partial<SessionEntry> = {
compactionCount: nextCount,
updatedAt: now,
};
// If tokensAfter is provided, update the cached token counts to reflect post-compaction state
if (tokensAfter != null && tokensAfter > 0) {
updates.totalTokens = tokensAfter;
updates.totalTokensFresh = true;
// Clear input/output breakdown since we only have the total estimate after compaction
updates.inputTokens = undefined;
updates.outputTokens = undefined;
updates.cacheRead = undefined;
updates.cacheWrite = undefined;
}
sessionStore[sessionKey] = {
...entry,
...updates,
};
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[sessionKey] = {
...store[sessionKey],
...updates,
};
});
}
return nextCount;
}