Files
openclaw/src/commitments/extraction.ts
Peter Steinberger 77d9ac30bb refactor: reuse shared coercion helpers (#86419)
* refactor: share talk event metric extraction

* refactor: reuse shared coercion helpers

* refactor: reuse shared primitive guards

* refactor: reuse shared record guard

* refactor: reuse shared primitive helpers

* refactor: reuse shared string guards

* refactor: reuse shared non-empty string guard

* refactor: share plugin primitive coercion helpers

* refactor: reuse plugin coercion helpers

* refactor: reuse plugin coercion helpers in more plugins

* refactor: reuse channel coercion helpers

* refactor: reuse monitor coercion helpers

* refactor: reuse provider coercion helpers

* refactor: reuse core coercion helpers

* refactor: reuse runtime coercion helpers

* refactor: reuse helper coercion in codex paths

* refactor: reuse helper coercion in runtime paths

* refactor: reuse codex app-server coercion helpers

* refactor: reuse codex record helpers

* refactor: reuse migration and qa record helpers

* refactor: reuse feishu and core helper guards

* refactor: reuse browser and policy coercion helpers

* refactor: reuse memory wiki record helper

* refactor: share boolean coercion helpers

* refactor: reuse finite number coercion

* refactor: reuse trimmed string list helpers

* refactor: reuse string list normalization

* refactor: reuse remaining string list helpers

* refactor: reuse string entry normalizer

* refactor: share sorted string helpers

* refactor: share string list normalization

* test: preserve command registry browser imports

* refactor: reuse trimmed list helpers

* refactor: reuse string dedupe helpers

* refactor: reuse local dedupe helpers

* refactor: reuse more string dedupe helpers

* refactor: reuse command string dedupe helpers

* refactor: dedupe memory path lists with helper

* refactor: expose string dedupe helpers to plugins

* refactor: reuse core string dedupe helpers

* refactor: reuse shared unique value helpers

* refactor: reuse unique helpers in agent utilities

* refactor: reuse unique helpers in config plumbing

* refactor: reuse unique helpers in extensions

* refactor: reuse unique helpers in core utilities

* refactor: reuse unique helpers in qa plugins

* refactor: reuse unique helpers in memory plugins

* refactor: reuse unique helpers in channel plugins

* refactor: reuse unique helpers in core tails

* refactor: reuse unique helper in comfy workflow

* refactor: reuse unique helpers in test utilities

* refactor: expose unique value helper to plugins

* refactor: reuse unique helpers for numeric lists

* refactor: replace index dedupe filters

* refactor: reuse string entry normalization

* refactor: reuse string normalization in plugin helpers

* refactor: reuse string normalization in extension helpers

* refactor: reuse string normalization in channel parsers

* refactor: reuse string normalization in memory search

* refactor: reuse string normalization in provider parsers

* refactor: reuse string normalization in qa helpers

* refactor: reuse string normalization in infra parsers

* refactor: reuse string normalization in messaging parsers

* refactor: reuse string normalization in core parsers

* refactor: reuse string normalization in extension parsers

* refactor: reuse string normalization in remaining parsers

* refactor: reuse string normalization in final parser spots

* refactor: reuse string normalization in qa media helpers

* refactor: reuse normalization in provider and media lists

* refactor: reuse normalization for remaining set filters

* refactor: reuse normalization in policy allowlists

* refactor: reuse normalization in session and owner lists

* refactor: centralize primitive string lists

* refactor: reuse lowercase entry helpers

* refactor: reuse sorted string helpers

* refactor: reuse unique trimmed helpers

* refactor: reuse string normalization helpers

* refactor: reuse catalog string helpers

* refactor: reuse remaining string helpers

* refactor: simplify remaining list normalization

* refactor: reuse codex auth order normalization

* chore: refresh plugin sdk api baseline

* fix: make shared string sorting deterministic

* chore: refresh plugin sdk api baseline

* fix: align host env security ordering
2026-05-25 21:20:41 +01:00

347 lines
11 KiB
TypeScript

import { resolveAgentConfig } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveHeartbeatIntervalMs } from "../infra/heartbeat-summary.js";
import { asFiniteNumber } from "../shared/number-coercion.js";
import { normalizeOptionalString as asString } from "../shared/string-coerce.js";
import { isRecord } from "../utils.js";
import { resolveCommitmentsConfig } from "./config.js";
import { listPendingCommitmentsForScope, upsertInferredCommitments } from "./store.js";
import type {
CommitmentCandidate,
CommitmentExtractionBatchResult,
CommitmentExtractionItem,
CommitmentKind,
CommitmentSensitivity,
CommitmentSource,
} from "./types.js";
const KIND_VALUES = new Set<CommitmentKind>([
"event_check_in",
"deadline_check",
"care_check_in",
"open_loop",
]);
const SENSITIVITY_VALUES = new Set<CommitmentSensitivity>(["routine", "personal", "care"]);
const SOURCE_VALUES = new Set<CommitmentSource>(["inferred_user_context", "agent_promise"]);
function asNumber(value: unknown): number | undefined {
return asFiniteNumber(value);
}
function parseCandidate(raw: unknown): CommitmentCandidate | undefined {
if (!isRecord(raw)) {
return undefined;
}
if (raw.action === "skip") {
return undefined;
}
const itemId = asString(raw.itemId);
const kind = asString(raw.kind);
const sensitivity = asString(raw.sensitivity);
const source = asString(raw.source) ?? "inferred_user_context";
const reason = asString(raw.reason);
const suggestedText = asString(raw.suggestedText);
const dedupeKey = asString(raw.dedupeKey);
const confidence = asNumber(raw.confidence);
const dueWindow = isRecord(raw.dueWindow) ? raw.dueWindow : undefined;
const earliest = asString(dueWindow?.earliest);
const latest = asString(dueWindow?.latest);
const timezone = asString(dueWindow?.timezone);
if (
!itemId ||
!KIND_VALUES.has(kind as CommitmentKind) ||
!SENSITIVITY_VALUES.has(sensitivity as CommitmentSensitivity) ||
!SOURCE_VALUES.has(source as CommitmentSource) ||
!reason ||
!suggestedText ||
!dedupeKey ||
confidence === undefined ||
!earliest
) {
return undefined;
}
return {
itemId,
kind: kind as CommitmentKind,
sensitivity: sensitivity as CommitmentSensitivity,
source: source as CommitmentSource,
reason,
suggestedText,
dedupeKey,
confidence,
dueWindow: {
earliest,
...(latest ? { latest } : {}),
...(timezone ? { timezone } : {}),
},
};
}
function extractJsonObjectCandidates(raw: string): string[] {
const out: string[] = [];
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
for (let idx = 0; idx < raw.length; idx += 1) {
const char = raw[idx] ?? "";
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
if (inString) {
escaped = true;
}
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === "{") {
if (depth === 0) {
start = idx;
}
depth += 1;
continue;
}
if (char === "}" && depth > 0) {
depth -= 1;
if (depth === 0 && start >= 0) {
out.push(raw.slice(start, idx + 1));
start = -1;
}
}
}
return out;
}
export function parseCommitmentExtractionOutput(raw: string): CommitmentExtractionBatchResult {
const candidates: CommitmentCandidate[] = [];
const trimmed = raw.trim();
if (!trimmed) {
return { candidates };
}
const records: Record<string, unknown>[] = [];
try {
const parsed = JSON.parse(trimmed) as unknown;
if (isRecord(parsed)) {
records.push(parsed);
}
} catch {
for (const candidate of extractJsonObjectCandidates(trimmed)) {
try {
const parsed = JSON.parse(candidate) as unknown;
if (isRecord(parsed)) {
records.push(parsed);
}
} catch {
// Ignore malformed fragments.
}
}
}
for (const record of records) {
const rawCandidates = Array.isArray(record.candidates) ? record.candidates : [];
for (const candidate of rawCandidates) {
const parsed = parseCandidate(candidate);
if (parsed) {
candidates.push(parsed);
}
}
}
return { candidates };
}
export async function hydrateCommitmentExtractionItem(params: {
cfg?: OpenClawConfig;
item: Omit<CommitmentExtractionItem, "existingPending">;
}): Promise<CommitmentExtractionItem> {
const existingPending = await listPendingCommitmentsForScope({
cfg: params.cfg,
scope: params.item,
nowMs: params.item.nowMs,
limit: 8,
});
return {
...params.item,
existingPending: existingPending.map((commitment) => ({
kind: commitment.kind,
reason: commitment.reason,
dedupeKey: commitment.dedupeKey,
earliestMs: commitment.dueWindow.earliestMs,
latestMs: commitment.dueWindow.latestMs,
})),
};
}
function formatExistingPending(item: CommitmentExtractionItem) {
return item.existingPending.map((commitment) => ({
kind: commitment.kind,
reason: commitment.reason,
dedupeKey: commitment.dedupeKey,
earliest: new Date(commitment.earliestMs).toISOString(),
latest: new Date(commitment.latestMs).toISOString(),
}));
}
export function buildCommitmentExtractionPrompt(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
}): string {
const items = params.items.map((item) => ({
itemId: item.itemId,
now: new Date(item.nowMs).toISOString(),
timezone: item.timezone,
latestUserMessage: item.userText,
assistantResponse: item.assistantText ?? "",
existingPendingCommitments: formatExistingPending(item),
}));
return `You are OpenClaw's internal commitment extractor. This is a hidden background classification run. Do not address the user.
Create inferred follow-up commitments only. Exact user requests such as "remind me tomorrow", "schedule this", or "check in at 3" belong to cron/reminders and must be skipped.
Use these categories: event_check_in, deadline_check, care_check_in, open_loop.
Create a candidate only when the latest exchange creates a useful future check-in opportunity that the user did not explicitly schedule. Prefer no candidate over weak candidates.
Rules:
- Output JSON only, with top-level {"candidates":[...]}.
- Each candidate must include itemId, kind, sensitivity, source, dueWindow, reason, suggestedText, confidence, and dedupeKey.
- kind is one of event_check_in, deadline_check, care_check_in, open_loop.
- sensitivity is routine, personal, or care.
- source is inferred_user_context or agent_promise.
- dueWindow.earliest and dueWindow.latest must be ISO timestamps in the future relative to that item.
- Skip explicit reminders/scheduling requests; those are cron-owned.
- Skip if the assistant already clearly says a cron reminder was scheduled.
- Skip if the topic is already resolved in the assistant response.
- Care check-ins must be gentle, rare, and high confidence. Avoid interrogating language.
- Suggested text should be short, natural, and suitable to send in the same channel.
- Dedupe keys should be stable within a session, like "interview:2026-04-29" or "sleep:2026-04-29".
Items:
${JSON.stringify(items, null, 2)}`;
}
function parseDueMs(raw: string | undefined): number | undefined {
if (!raw) {
return undefined;
}
const parsed = Date.parse(raw);
return Number.isFinite(parsed) ? parsed : undefined;
}
function resolveMinimumDueMs(params: {
cfg?: OpenClawConfig;
item: CommitmentExtractionItem;
nowMs: number;
}): number {
const cfg = params.cfg ?? {};
const defaults = cfg.agents?.defaults?.heartbeat;
const overrides = resolveAgentConfig(cfg, params.item.agentId)?.heartbeat;
const heartbeat = defaults || overrides ? { ...defaults, ...overrides } : undefined;
const intervalMs = resolveHeartbeatIntervalMs(cfg, undefined, heartbeat) ?? 0;
return params.nowMs + intervalMs;
}
export function validateCommitmentCandidates(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
result: CommitmentExtractionBatchResult;
nowMs?: number;
}): Array<{
item: CommitmentExtractionItem;
candidate: CommitmentCandidate;
earliestMs: number;
latestMs: number;
timezone: string;
}> {
const resolved = resolveCommitmentsConfig(params.cfg);
const itemsById = new Map(params.items.map((item) => [item.itemId, item]));
const nowMs = params.nowMs ?? Date.now();
const validated: Array<{
item: CommitmentExtractionItem;
candidate: CommitmentCandidate;
earliestMs: number;
latestMs: number;
timezone: string;
}> = [];
for (const candidate of params.result.candidates) {
const item = itemsById.get(candidate.itemId);
if (!item) {
continue;
}
const threshold =
candidate.kind === "care_check_in" || candidate.sensitivity === "care"
? resolved.extraction.careConfidenceThreshold
: resolved.extraction.confidenceThreshold;
if (candidate.confidence < threshold) {
continue;
}
const extractedEarliestMs = parseDueMs(candidate.dueWindow.earliest);
if (extractedEarliestMs === undefined || extractedEarliestMs <= item.nowMs) {
continue;
}
const earliestMs = Math.max(
extractedEarliestMs,
resolveMinimumDueMs({
cfg: params.cfg,
item,
nowMs,
}),
);
const latestRawMs = parseDueMs(candidate.dueWindow.latest);
const latestMs =
latestRawMs !== undefined && latestRawMs >= earliestMs
? latestRawMs
: earliestMs + 12 * 60 * 60 * 1000;
validated.push({
item,
candidate,
earliestMs,
latestMs,
timezone: candidate.dueWindow.timezone ?? item.timezone,
});
}
return validated;
}
export async function persistCommitmentExtractionResult(params: {
cfg?: OpenClawConfig;
items: CommitmentExtractionItem[];
result: CommitmentExtractionBatchResult;
nowMs?: number;
}) {
const valid = validateCommitmentCandidates(params);
const byItem = new Map<string, typeof valid>();
for (const entry of valid) {
const existing = byItem.get(entry.item.itemId) ?? [];
existing.push(entry);
byItem.set(entry.item.itemId, existing);
}
const created = [];
for (const entries of byItem.values()) {
const item = entries[0]?.item;
if (!item) {
continue;
}
created.push(
...(await upsertInferredCommitments({
cfg: params.cfg,
item,
candidates: entries.map((entry) => ({
candidate: entry.candidate,
earliestMs: entry.earliestMs,
latestMs: entry.latestMs,
timezone: entry.timezone,
})),
nowMs: params.nowMs,
})),
);
}
return created;
}