import { type ChatTurnWriterStateLayout } from "./state-dir-path.js"; /** * Durable direct-channel marker lifecycle: * `markExternalTurnPersistedDurable` creates markers only after channel-side * daemon `storeChatTurn` succeeds; exact marker keys include `turnId` plus * canonical user/assistant text to avoid false dedupe for reused IDs or * content. Direct-channel transcripts also get a `turnId` + canonical user-body * marker because OpenClaw can retain a rendered assistant shape that differs * from the final daemon-persisted text, plus one ordered marker for later * transcripts that have stripped direct-channel metadata entirely. W4a * matches them in * `consumeExternalTurnMarkersForPair` during `runAgentEndPersist`, advancing * pair watermarks only after durable commit; the ordered fallback is limited * to historical non-latest pairs so it cannot drop the live Telegram turn. * Create failures roll back marker snapshots when `commitWatermarkStateSync` * fails; `setStateDir` migrates per-session `m` * markers, and graceful `DkgChannelPlugin.stop()` drains in-flight first writes. * Telegram persistence is owned by W4a `agent_end` plus the colon-form * internal W4b hooks (`message:received` / `message:sent`). W4b replay * markers are retained and bound to provider message IDs so repeated * reset/compaction replays skip same-text occurrences safely. */ interface Logger { info?: (...args: unknown[]) => void; warn?: (...args: unknown[]) => void; error?: (...args: unknown[]) => void; debug?: (...args: unknown[]) => void; } export interface ChatTurnMessage { role: "user" | "assistant" | "system" | "tool"; content: string | Array<{ type: string; text?: string; }>; context?: Record; metadata?: Record; [k: string]: unknown; /** * Optional list of tool invocations the model issued in this assistant * step. Present on intermediate assistant messages that exist solely to * call a tool (no user-visible reply text); absent on the final reply. * Used by `computeDelta` to skip those intermediates so a tool-using * turn is persisted as one (user, final-assistant-reply) pair, not * one pair per intermediate assistant step. */ toolCalls?: Array; tool_calls?: Array; } export interface AgentEndContext { sessionId: string; messages: ChatTurnMessage[]; } /** * Canonical shape mirrors `InternalHookEvent` from * `@openclaw/openclaw/src/hooks/internal-hook-types.ts`: * - `sessionKey` is at the event root * - actual message text + envelope metadata live on `event.context.content`, * `event.context.channelId`, `event.context.success`, etc. * * `text` and `direction` at the root are accepted as a back-compat / test * fixture shorthand; production gateway envelopes always use `context`. */ export interface InternalMessageEvent { sessionKey: string; direction?: "inbound" | "outbound"; text?: string; context?: { content?: string; channelId?: string; accountId?: string; conversationId?: string; success?: boolean; [k: string]: unknown; }; } export declare class ChatTurnWriter { private client; private logger; private stateDir; private stateLayout; private cachedWatermarks; private pendingUserMessages; private pendingUserMessageMeta; private pendingUserMessageSeq; private debounceTimers; private watermarkFilePath; private recentTurnIds; private static readonly TURNID_TTL_MS; private w4bSessionCounts; private externalTurnMarkers; private static readonly EXTERNAL_ORDERED_TURN_MARKER; private untrustedDaemonCursorKeys; private durableCursorKeyRevisions; private inFlightPersists; private persistJobOriginKeys; private pendingResets; private w4aSessionChains; private crossPathStamps; private static readonly CROSS_PATH_TTL_MS; private crossPathInflight; private static readonly CROSS_PATH_INFLIGHT_TTL_MS; private messageHookDedup; private messageHookInboundQueueKeys; private messageHookDedupSessionKeys; private messageHookNoIdInboundBatch; private messageHookNoIdInboundBatchClearScheduled; private static readonly MESSAGE_HOOK_DEDUP_TTL_MS; private static readonly MESSAGE_HOOK_DEDUP_SWEEP_INTERVAL_MS; private static readonly MESSAGE_HOOK_DEDUP_MAX_ENTRIES; private static readonly MESSAGE_HOOK_NO_ID_INBOUND_PREFIX; private messageHookLastSweepAt; private static readonly WEAK_SESSION_PREFIX; constructor(options: { client: any; logger: Logger; stateDir: string; stateLayout?: ChatTurnWriterStateLayout; legacyStateDirs?: string[]; }); setClient(client: any): void; /** * T18/T21/T22 — Migrate this writer to a new stateDir without losing * in-flight work or rolling back newer state at the destination. * * Steps (in order): * 1. `await flush()` — drain in-flight persists, pending resets, * and per-session agent_end chains so we have a stable * in-memory snapshot at the OLD path. T21 regression fix: * pre-fix the migration used `flushSync()`, which only wrote * the debounced watermark and missed in-flight `storeChatTurn` * jobs that completed after the swap. * 2. Read the destination watermark file (if any) and MERGE * per-session via max(w) and max(b). T22 regression fix: * pre-fix unconditionally overwrote the destination, which * rolled back newer state from a prior run at the workspace * path. The destination file's session keys may also reference * conversations this process never touched — those are * preserved unchanged. * 3. Update internal paths atomically. * 4. Write the merged state to the new location. * 5. Leave the old file untouched so failed writes can retry and * operator-owned fallback paths are not silently deleted. */ setStateDir(newStateDir: string, options?: { stateLayout?: ChatTurnWriterStateLayout; legacyStateDirs?: string[]; }): Promise; private initFromFile; private migrateLegacyWatermarkFiles; private legacyWatermarkFilePathsForLayout; private mergeLegacyWatermarkFilesInto; private legacyMigrationMarkerPath; private readLegacyMigrationSourceHashes; private hashWatermarkFileForMigration; private recordLegacyMigrationSources; private mergeWatermarkFileInto; onAgentEnd(event: AgentEndContext, ctx?: any): Promise; private runAgentEndPersist; /** * Wrap a persist job in the per-session `inFlightPersists` set so * `resetSessionState()` can `Promise.allSettled` everything that's * still running. Both W4a and W4b persist paths route through here so * the reset gate can't miss a fire-and-forget write. */ private trackPersistJob; private markPersistJobOrigin; private hasInFlightPersistOrigin; private hasAnyInFlightPersistOrigin; private hasW4bInflightOrigin; private deleteTrackedPersistJob; onBeforeCompaction(event: any, ctx?: any): Promise; onBeforeReset(event: any, ctx?: any): Promise; markExternalTurnPersistedDurable(opts: { sessionKey?: string; turnId?: string; user: string; userAliases?: string[]; assistant: string; }): Promise; private incrementOrderedExternalTurnMarker; private restoreFailedMigrationDestination; /** * Track the reset promise on `pendingResets` so `onAgentEnd` / * `onMessageSent` can `await` it before processing a turn that arrived * mid-reset. Without this gate, a fast post-compaction `agent_end` * could read the stale watermark before the reset finishes draining. */ private runReset; /** * Clear all session state for a single session: pending debounce timer, * cached watermark, dedup reservations, AND any in-flight `persistOne` * jobs are awaited before the wipe. No-op when `sessionId` is empty. * * In-flight tracking is the load-bearing piece — without it, an `agent_end` * fires `persistOne` (fire-and-forget) and IMMEDIATELY a compaction event * arrives. The reset clears the watermark to -1, then the still-running * `persistOne` calls `saveWatermark(0)`, leaving stale state for the next * `agent_end` against a smaller post-compaction array. */ private resetSessionState; onTypedMessageReceived(event: any, ctx?: any): void; onTypedMessageSent(event: any, ctx?: any): Promise; private normalizeTypedMessageEvent; private extractTypedMessageContent; private typedHookMessageId; private typedHookConversationId; onMessageReceived(ev: InternalMessageEvent): void; onMessageSent(ev: InternalMessageEvent): Promise; /** * Cross-path dedup check. Returns `true` if `turnId` was already seen * within TTL (caller should skip the persist); `false` and reserves the * id otherwise. The reservation must be released via * `releaseTurnIdReservation(turnId)` on persist failure so retries are * not blocked by a stale mark. Evicts expired ids opportunistically. */ private dedupKey; private markTurnIdSeen; private messageEventId; private nextPendingUserArrivalId; private pendingUserArrivalOrder; private messageHookDedupKey; private markMessageHookDuplicate; private isNoIdInboundBatchKey; private markNoIdInboundBatchDuplicate; private deleteMessageHookDedupKey; private clearMessageHookDedupForSessions; private messageHookInboundDedupKeysForQueue; private clearMessageHookInboundDedupKeys; private deleteMessageHookInboundDedupKeys; private shouldMoveInboundQueue; private pendingQueueUsesTypedSessionFallback; private shouldMoveInboundQueueForOutbound; private promotePendingInboundQueueForOutbound; private movePendingInboundQueue; private rebindMessageHookInboundQueueKeys; private w4bInflightGuardSessionIds; private w4aCrossPathSessionIds; /** * Non-mutating presence check. Returns `true` if the key is currently * reserved within the TTL window; `false` otherwise. Does NOT stamp. * * Use for OPPOSITE-path guards (W4a peeking w4b-origin, W4b peeking * w4a-origin). The set-on-miss behavior of `markTurnIdSeen` is wrong * for the opposite-path check because the peeker would falsely * reserve a key it has no business owning, then dedup against itself * on the next legitimate same-content turn within the TTL. * * Evicts the entry opportunistically if it's expired so the read is * accurate rather than stale. */ private peekTurnIdSeen; /** Release a turnId reservation on persist failure so retries can proceed. */ private releaseTurnIdReservation; /** * T5 — Set semantics on the SHORT-TTL cross-path map. Used for * `w4aOrigin` / `w4bOrigin` content-only stamps. Lifetime is * `CROSS_PATH_TTL_MS` (5s) — long enough for the opposite path to * fire on the same logical turn, short enough that a repeated same- * content turn outside the window doesn't false-dedup. * Opportunistic eviction prevents unbounded growth. */ private markCrossPathStamp; /** * T16 — Consume a cross-path stamp after a successful peek-hit. * The 5s TTL is generous to cover slow channels, but a content-only * stamp left in place can false-dedup a legitimate same-content * turn that arrives within the window. Consuming on peek-hit * narrows the false-dedup risk to the very specific case where * the OWNING path (e.g., W4a) fires for turn 1 but skips turn 2, * within the same 5s window. Each path consumes at most ONE stamp * per logical turn (W4a's last-pair peek and W4b's pre-persist * peek both run once per turn). */ private consumeCrossPathStamp; /** * T5 — Non-mutating presence check on the cross-path map. Returns * `true` if the key is currently within the 5s window. */ private peekCrossPathStamp; /** * T10 — Mark a cross-path in-flight reservation pre-persist. The * opposite path's `peekCrossPathInflight` then catches the active * race and skips its own persist. Always paired with `unmarkCrossPathInflight` * in a `finally` block so failures don't leak the reservation. */ private markCrossPathInflight; /** T10 — Release a cross-path in-flight reservation. */ private unmarkCrossPathInflight; /** * T10 — Non-mutating presence check on the in-flight map. Returns * `true` if the opposite path is currently mid-persist for this * content. Defensive timestamp eviction guards against leaked * entries from a missed `finally`. */ private peekCrossPathInflight; /** Drop all dedup reservations belonging to one session. */ private clearSessionTurnIds; /** * Drain everything before shutdown. Awaits all in-flight `persistOne` * jobs across every session, settles any pending session reset, and * commits the watermark file. `stop()` callers MUST await this — a * sync `flushSync()` only commits the file but leaves a fire-and-forget * `storeChatTurn()` in flight, so a shutdown right after a reply could * exit before the final turn is persisted to the daemon. * * R19.2 — Loops until `inFlightPersists` and `pendingResets` are both * empty. A previously-dispatched hook handler (e.g., `agent_end` / * `message:sent`) can still be running when `stop()` calls `flush()`; * if it reaches `trackPersistJob` AFTER our snapshot but BEFORE * `Promise.allSettled` returns, the job would otherwise be missed. * Re-snapshotting and re-awaiting closes that race. Bounded because * `stop()` calls `hookSurface.destroy()` BEFORE `flush()` (R19.2), * so no NEW handler invocations are dispatched while flush runs — * only the in-flight ones complete. */ flush(): Promise; flushSync(): void; private applyPendingWatermarks; private commitWatermarkStateSync; private savedUpToForSession; private markDurableCursorKeyUntrusted; private markDurableCursorKeyTrusted; private durableCursorRevision; private bumpDurableCursorKeyRevision; private markAllDurableCursorKeysUntrusted; private hasDurableCursorState; private validateUntrustedDurableCursorsBeforeW4a; private clearStaleDurableCursorKeys; private snapshotDurableCursorKeys; private snapshotDurableCursorKey; private clearStaleDurableCursorKeyFromSnapshot; private clearAllDurableCursorStateForKey; private uniqueStrings; private snapshotWatermarksForWrite; private snapshotWatermarkState; private restoreWatermarkState; /** * Return every unsaved (user, assistant) pair in order. `savedUpTo` is a * pair-count watermark: -1 means nothing saved, 0 means the first pair * has been saved, and so on. Iterates the full message array and emits * pairs whose 0-indexed position exceeds the watermark — a transient * failure during a previous call leaves earlier pairs unsaved, and the * next `onAgentEnd` will backfill them rather than dropping everything * except the most recent pair. */ private computeDelta; private hasToolCalls; private isTranscriptScaffoldingMessage; /** * Strip leading runtime-only channel metadata blocks from persisted user text. * * OpenClaw's Telegram channel plugin can prepend fenced JSON context for the * agent (Telegram conversation/sender details). * That metadata is useful before the model responds, but persisting it as user * text pollutes recall and may leak sender/chat identifiers. * * Call this only after trusted channel context says the source is Telegram; * the labels below are user-writable text without that channel context. Keep * the recognized labels to the concrete Telegram wrapper shape; broader * channel/message labels need their own trusted source before they can be * stripped safely. The leading `Conversation info` block is stripped, and the * immediately following `Sender` block is stripped only when its JSON agrees * with that conversation block. Standalone `Sender` blocks are preserved as * user-authored text because there is no trusted wrapper context to bind them * to. Separator blank lines after stripped blocks are removed, but indentation * on the first real user line is kept. * * Because W4a and W4b both call this before turnId/content hashing, metadata * changes do not create distinct persisted turn identities for the same user * utterance. Existing historical turns are not rewritten. */ private shouldStripChannelMetadataForChannel; private stripChannelMetadata; private matchLeadingChannelMetadataBlock; private senderMetadataMatchesConversation; private parseChannelMetadataJson; /** * Strip the auto-injected `` block from assistant text * before persistence. Prevents the per-turn auto-recall block from * boomeranging into future turn queries if the model verbatim-quotes * system-context. * * R15.3 — Only strip blocks that carry the `data-source="dkg-auto-recall"` * sentinel emitted by `formatRecalledMemoryBlock` in DkgNodePlugin.ts. * A user-emitted plain `` literal (XML examples, * documentation, debugging output) survives verbatim in the persisted * transcript. The sentinel match is case-insensitive on the tag/attribute * names but the value `dkg-auto-recall` is matched as-is. * * Handles: * - well-formed sentinel pairs `...` * - orphaned sentinel open tag at end-of-text (truncated model output) * * The sentinel value is load-bearing — keep in sync with * `formatRecalledMemoryBlock` in DkgNodePlugin.ts. */ private stripRecalledMemory; private extractExternalTurnIds; private extractMessageIds; private hasExternalDirectChannelMetadata; /** * Strip control chars and bound length without dropping the * distinguishing suffix. R13.2 — naive `substring(0, 64)` collapsed * distinct long `channelId` / `accountId` / `conversationId` / * `sessionKey` values that shared a long prefix into one composed * key, merging unrelated conversations' watermarks. Keep a readable * prefix; append a stable hash suffix so distinct overlong values * always produce distinct outputs. */ private static readonly SANITIZE_MAX_LEN; private static readonly SANITIZE_HASH_LEN; private sanitize; /** * Cross-path dedup keys. Each path stamps its OWN origin key when it * persists; each path checks the OTHER path's origin key before * persisting. This makes dedup symmetric — neither order causes a * double-write. * * - W4a stamps `w4a-content::` after each successful persist. * `onMessageSent` (W4b) checks this up-front: if W4a already wrote * the same content within the TTL, skip. * - W4b reserves `w4b-content::` BEFORE persist (atomic mark). * `onAgentEnd`'s LAST pair (the most-recent turn that W4b could * plausibly have just persisted) checks this; backfill pairs * (earlier pair indices) do not, because they correspond to * historical turns W4b never saw. * * Hash includes only `user:assistant` text (no sessionId, no pair * index) — both paths see the same canonical content for the same * exchange and produce the same hash. */ /** * R17.1 — Hash a STRUCTURED encoding (not raw `:`-joined) so a literal * `:` inside `user` or `assistant` cannot bleed across the boundary * and let two distinct turns collide on the same digest. Without * this, `(user="a:b", assistant="c")` and `(user="a", assistant="b:c")` * both hashed `"a:b:c"` and the cross-path dedup map treated them as * the same turn. `JSON.stringify` quotes and escapes each segment * unambiguously. */ private contentHash; private w4aOriginKey; private w4bOriginKey; private identityHash; private weakSessionKey; private isWeakSessionKey; private externalTurnMarkerId; private externalTurnUserMarkerId; private typedW4bExternalMarkerId; private typedW4bExternalMarkerIds; private typedW4bInflightOrigin; private typedW4bInflightOrigins; private typedW4bInflightOriginsFromOccurrences; private typedW4bInflightOriginsForMarkers; private findTypedW4bExternalMarker; private markTypedW4bTurnPersisted; private consumeExternalTurnMarkersForPair; /** * Returns true when some later pair in this `agent_end` carries a * direct-channel exact marker whose `(turnId, user, assistant)` would * match the SUPPLIED `user`/`assistant` content. Used to block * ordered-fallback skipping for an earlier metadata-less pair whose * content the later exact marker will already deduplicate against — * see T84: a real W4a turn that coincidentally shares text with a * later direct-persisted pair must persist, not be ordered-skipped. * * A later exact marker for SOME OTHER direct turn (different content) * must NOT block ordered dedupe of this stripped pair — that's the * pre-existing over-coarse check this PR is replacing. */ private hasLaterExactMarkerForPairContent; private hasExternalTurnMarker; private consumeExternalTurnMarker; private restoreExternalTurnMarker; private restoreExternalTurnMarkerCount; private clearExternalOrderedTurnMarker; private cloneExternalTurnMarkers; private mergeExternalTurnMarkers; /** * R15.1 — Per-turn in-flight reservation key for the W4b path. * Distinct from the cross-path `w4bOrigin` (which is content-only and * held post-success so W4a's last-pair peek can find it). This key * exists only to dedup CONCURRENT same-content `message:sent` * dispatches for the same logical turn — released on persist * completion (success or failure). * * Prefer the gateway-provided `messageId` (one-per-delivery guarantee * from `openclaw/src/infra/outbound/deliver.ts:381`). Fall back to a * monotonic in-process sequence when the envelope omits it; in that * fallback the in-flight guard becomes effectively a no-op (each fire * gets a unique key), which is acceptable because messageId-less * envelopes don't exhibit the race in practice. */ private w4bInflightSeq; private w4bInflightKey; /** * T33 — Discriminator mixed into the W4b daemon-facing turnId so the * resulting hash is unique per logical turn AND durable across process * restart. Prefers `messageId` (durable: the gateway persists outbound * delivery records keyed by it). Falls back to a sequence counter for * messageId-less envelopes — the fallback is NOT durable across * restart, but in practice OpenClaw's outbound path always carries * messageId; the fallback exists only so test fixtures and pathological * envelopes don't crash here. * * Distinct from `w4bInflightKey`'s sequence fallback, which intentionally * always varies per-call (own-path concurrent dispatch dedup). The * daemon-id fallback can collide on same-content same-session same- * fallback-counter — but the messageId path is the production case * and is unambiguously stable. */ private w4bDaemonTurnIdDiscriminator; private w4bMessageDiscriminator; private w4bInboundMessageDiscriminator; private w4bOutboundMessageDiscriminators; private typedW4bMarkerOccurrences; /** * @deprecated Kept temporarily for tests that inspect the dedup-map key * shape; new code should use `w4aOriginKey` / `w4bOriginKey`. */ private contentAliasKey; /** * R17.1 — Hash a STRUCTURED encoding (not raw `:`-joined) so a literal * `:` inside any segment cannot let two distinct turns collide. The * raw-join form had the same delimiter-collision bug as `contentHash`: * `(sessionId="s:1", user="u")` and `(sessionId="s", user="1:u")` both * hashed to `"s:1:u:..."`. `JSON.stringify` quotes each segment. */ private deterministicTurnId; /** * DKG-side session id from the typed-hook `ctx`. Channels like Telegram * can legitimately share a `sessionKey` across threads, so the id also * includes `accountId` + `conversationId` when the gateway provides * them. Missing discriminators fall back to empty strings, keeping the * id stable across paths for the same conversation — and matching * `deriveSessionIdFromEvent` for dedup. */ private deriveSessionId; private identityFieldsFromPayload; private resetIdentityFromHookPayload; /** * DKG-side session id for an internal message event. Uses the full * envelope (`channelId + accountId + conversationId + sessionKey`) * so threads that legitimately share a `sessionKey` on the same * channel still persist to distinct DKG sessions — and turns across * those threads can't be mis-dedup'd as duplicates. */ private deriveSessionIdFromEvent; /** * Per-field encoder used before joining session-id segments with `:`. * Without encoding, raw `channelId` / `accountId` / `conversationId` / * `sessionKey` values that legitimately contain `:` (e.g. OpenClaw's * own `agent::` session keys) collapse different * tuples to the same composed string and merge unrelated conversations' * watermarks and pending queues. * * Percent-encode `:` as `%3A` and `%` as `%25` (so the encoding is * reversible). Cheap, deterministic, no third-party dependency. */ private encodeIdField; private composeSessionId; private externalCursorKeyFromSessionKey; private weakConversationCursorKey; private weakConversationCursorKeyFromSessionId; private concreteSessionCursorKey; private concreteSessionCursorKeyFromSessionId; private conversationlessSessionCursorKey; private conversationlessSessionCursorKeyFromSessionId; private typedW4bMarkerCursorKeys; private typedW4bMarkerCursorKeysFromSessionId; private collectResetSessionIds; private collectKnownSessionIds; private sessionIdFromCompositeDedupKey; private parseComposedSessionId; /** * Pending-message lookup key. Must distinguish every in-flight conversation * the gateway is juggling, so it includes channel + account + conversation + * sessionKey. Two Telegram threads sharing a sessionKey still get separate * slots, preventing reply mis-pairing. */ private conversationKeyFromInternalEvent; private extractText; private loadWatermark; /** * Advance the per-session watermark to `pairIndex` if (and only if) it * is greater than the current pending or persisted index. Centralizes * the "MAX of current and pairIndex" guard so the cross-path-skip path * (R14.1) and `persistOne` use identical advancement semantics. */ private bumpWatermark; private saveWatermark; /** * T17 — Schedule a debounced watermark-file flush WITHOUT changing * the pending watermark value. Used by W4b's `w4bSessionCounts` * increment so the new count lands on disk via the same file write * that watermark updates use. Retry flushes may take over an existing * non-retry debounce timer while preserving that timer's pending * watermark index. */ private scheduleWatermarkFlush; private persistOne; private writeWatermarkFile; } export {}; //# sourceMappingURL=ChatTurnWriter.d.ts.map