diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md
index c21de4d19d9..c16ab0b6c33 100644
--- a/docs/channels/imessage.md
+++ b/docs/channels/imessage.md
@@ -442,6 +442,13 @@ See [ACP Agents](/tools/acp-agents) for shared ACP binding behavior.
Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, history settings, and attachment root allowlists.
+
+
+ Set `channels.imessage.dmHistoryLimit` to seed new direct-message sessions with recent decoded `imsg` history for that conversation. Use `channels.imessage.dms[""].historyLimit` for per-sender overrides, including `0` to disable history for a sender.
+
+ iMessage DM history is fetched on demand from `imsg`. Leaving `dmHistoryLimit` unset disables global DM history seeding, but a positive per-sender `channels.imessage.dms[""].historyLimit` still enables seeding for that sender.
+
+
## Media, chunking, and delivery targets
diff --git a/extensions/imessage/src/monitor/dm-history.test.ts b/extensions/imessage/src/monitor/dm-history.test.ts
new file mode 100644
index 00000000000..56f0a2e7ad3
--- /dev/null
+++ b/extensions/imessage/src/monitor/dm-history.test.ts
@@ -0,0 +1,102 @@
+import { resolveEnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
+import { describe, expect, it, vi } from "vitest";
+import type { IMessageRpcClient } from "../client.js";
+import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js";
+
+describe("resolveIMessageDmHistoryLimit", () => {
+ it("uses per-DM history overrides before the provider default", () => {
+ expect(
+ resolveIMessageDmHistoryLimit({
+ config: {
+ dmHistoryLimit: 5,
+ dms: {
+ "+15555550123": { historyLimit: 2 },
+ },
+ },
+ sender: "+1 (555) 555-0123",
+ senderNormalized: "+15555550123",
+ }),
+ ).toBe(2);
+ });
+
+ it("defaults to disabled when no iMessage DM history limit is configured", () => {
+ expect(resolveIMessageDmHistoryLimit({ config: {}, sender: "+15555550123" })).toBe(0);
+ });
+});
+
+describe("resolveIMessageDmHistoryContext", () => {
+ it("fetches decoded imsg history rows and excludes the current message", async () => {
+ const request = vi.fn(async () => ({
+ messages: [
+ {
+ id: 8,
+ guid: "previous-in",
+ chat_id: 44,
+ sender: "+15555550123",
+ is_from_me: false,
+ text: "earlier inbound",
+ created_at: "2026-05-25T12:00:00.000Z",
+ is_group: false,
+ },
+ {
+ id: 9,
+ guid: "previous-out",
+ chat_id: 44,
+ sender: null,
+ is_from_me: true,
+ text: "earlier outbound",
+ created_at: "2026-05-25T12:01:00.000Z",
+ is_group: false,
+ },
+ {
+ id: 10,
+ guid: "current",
+ chat_id: 44,
+ sender: "+15555550123",
+ is_from_me: false,
+ text: "current",
+ created_at: "2026-05-25T12:02:00.000Z",
+ is_group: false,
+ },
+ ],
+ }));
+
+ const context = await resolveIMessageDmHistoryContext({
+ client: { request } as unknown as IMessageRpcClient,
+ message: {
+ id: 10,
+ guid: "current",
+ chat_id: 44,
+ sender: "+15555550123",
+ text: "current",
+ is_from_me: false,
+ is_group: false,
+ },
+ senderNormalized: "+15555550123",
+ limit: 2,
+ envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
+ });
+
+ expect(request).toHaveBeenCalledWith(
+ "messages.history",
+ { chat_id: 44, limit: 3, attachments: false },
+ { timeoutMs: 10_000 },
+ );
+ expect(context.inboundHistory).toEqual([
+ {
+ sender: "+15555550123",
+ body: "earlier inbound",
+ timestamp: Date.parse("2026-05-25T12:00:00.000Z"),
+ },
+ {
+ sender: "Me",
+ body: "earlier outbound",
+ timestamp: Date.parse("2026-05-25T12:01:00.000Z"),
+ },
+ ]);
+ expect(context.body).toContain("earlier inbound");
+ expect(context.body).toContain("earlier outbound");
+ expect(context.body).not.toContain("current");
+ });
+});
diff --git a/extensions/imessage/src/monitor/dm-history.ts b/extensions/imessage/src/monitor/dm-history.ts
new file mode 100644
index 00000000000..bcd044589c9
--- /dev/null
+++ b/extensions/imessage/src/monitor/dm-history.ts
@@ -0,0 +1,166 @@
+import {
+ formatInboundEnvelope,
+ type resolveEnvelopeFormatOptions,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
+import type { IMessageRpcClient } from "../client.js";
+import { normalizeIMessageHandle } from "../targets.js";
+import { parseIMessageNotification } from "./parse-notification.js";
+import type { IMessagePayload } from "./types.js";
+
+const DM_HISTORY_RPC_TIMEOUT_MS = 10_000;
+
+type IMessageHistoryResult = {
+ messages?: unknown[];
+};
+
+type IMessageDmHistoryConfig = {
+ dmHistoryLimit?: number;
+ dms?: Record;
+};
+
+export type IMessageDmHistoryEntry = {
+ sender: string;
+ body: string;
+ timestamp?: number;
+};
+
+export type IMessageDmHistoryContext = {
+ body?: string;
+ inboundHistory?: IMessageDmHistoryEntry[];
+};
+
+export function resolveIMessageDmHistoryLimit(params: {
+ config: IMessageDmHistoryConfig;
+ sender?: string;
+ senderNormalized?: string;
+}): number {
+ const senderCandidates = [
+ normalizeOptionalString(params.senderNormalized),
+ normalizeOptionalString(params.sender),
+ params.sender ? normalizeIMessageHandle(params.sender) : undefined,
+ ].filter((candidate): candidate is string => Boolean(candidate));
+
+ for (const candidate of senderCandidates) {
+ const override = params.config.dms?.[candidate]?.historyLimit;
+ if (override !== undefined) {
+ return Math.max(0, override);
+ }
+ }
+
+ return Math.max(0, params.config.dmHistoryLimit ?? 0);
+}
+
+function historyRowSortValue(message: IMessagePayload): number {
+ if (typeof message.id === "number" && Number.isFinite(message.id)) {
+ return message.id;
+ }
+ const createdAtMs =
+ typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN;
+ return Number.isFinite(createdAtMs) ? createdAtMs : 0;
+}
+
+function isBeforeCurrentMessage(params: {
+ message: IMessagePayload;
+ currentMessage: IMessagePayload;
+}): boolean {
+ const { message, currentMessage } = params;
+ if (
+ typeof message.id === "number" &&
+ typeof currentMessage.id === "number" &&
+ Number.isFinite(message.id) &&
+ Number.isFinite(currentMessage.id)
+ ) {
+ return message.id < currentMessage.id;
+ }
+ const guid = normalizeOptionalString(message.guid);
+ const currentGuid = normalizeOptionalString(currentMessage.guid);
+ if (guid && currentGuid) {
+ return guid !== currentGuid;
+ }
+ return true;
+}
+
+function historyEntryFromMessage(message: IMessagePayload, fallbackSender: string) {
+ const body = normalizeOptionalString(message.text);
+ if (!body) {
+ return null;
+ }
+ const timestamp =
+ typeof message.created_at === "string" ? Date.parse(message.created_at) : Number.NaN;
+ return {
+ sender:
+ message.is_from_me === true
+ ? "Me"
+ : normalizeIMessageHandle(normalizeOptionalString(message.sender) ?? fallbackSender) ||
+ fallbackSender,
+ body,
+ ...(Number.isFinite(timestamp) ? { timestamp } : {}),
+ };
+}
+
+export async function resolveIMessageDmHistoryContext(params: {
+ client: IMessageRpcClient;
+ message: IMessagePayload;
+ senderNormalized: string;
+ limit: number;
+ envelopeOptions: ReturnType;
+ logVerbose?: (msg: string) => void;
+}): Promise {
+ const maxMessages = Math.max(0, Math.floor(params.limit));
+ const chatId =
+ typeof params.message.chat_id === "number" && Number.isFinite(params.message.chat_id)
+ ? params.message.chat_id
+ : undefined;
+ if (maxMessages <= 0 || chatId === undefined) {
+ return {};
+ }
+
+ let result: IMessageHistoryResult | undefined;
+ try {
+ result = await params.client.request(
+ "messages.history",
+ {
+ chat_id: chatId,
+ limit: maxMessages + 1,
+ attachments: false,
+ },
+ { timeoutMs: DM_HISTORY_RPC_TIMEOUT_MS },
+ );
+ } catch (err) {
+ params.logVerbose?.(`imessage: DM history fetch failed for chat_id=${chatId}: ${String(err)}`);
+ return {};
+ }
+
+ const rows = Array.isArray(result?.messages) ? result.messages : [];
+ const history = rows
+ .map((row) => parseIMessageNotification({ message: row }))
+ .filter((message): message is IMessagePayload => Boolean(message))
+ .filter((message) => message.is_group !== true)
+ .filter((message) => isBeforeCurrentMessage({ message, currentMessage: params.message }))
+ .toSorted((a, b) => historyRowSortValue(a) - historyRowSortValue(b))
+ .map((message) => historyEntryFromMessage(message, params.senderNormalized))
+ .filter((entry): entry is IMessageDmHistoryEntry => Boolean(entry))
+ .slice(-maxMessages);
+
+ if (history.length === 0) {
+ return {};
+ }
+
+ return {
+ inboundHistory: history,
+ body: history
+ .map((entry) =>
+ formatInboundEnvelope({
+ channel: "iMessage",
+ from: entry.sender,
+ timestamp: entry.timestamp,
+ body: entry.body,
+ chatType: "direct",
+ senderLabel: entry.sender,
+ envelope: params.envelopeOptions,
+ }),
+ )
+ .join("\n\n"),
+ };
+}
diff --git a/extensions/imessage/src/monitor/inbound-processing.test.ts b/extensions/imessage/src/monitor/inbound-processing.test.ts
index 46afe5cf407..0345286a447 100644
--- a/extensions/imessage/src/monitor/inbound-processing.test.ts
+++ b/extensions/imessage/src/monitor/inbound-processing.test.ts
@@ -794,6 +794,62 @@ describe("buildIMessageInboundContext", () => {
expect(ctxPayload.MessageSid).toBe("1");
expect(ctxPayload.MessageSidFull).toBe("p:0/GUID-current");
});
+
+ it("prepends direct-message history when supplied", async () => {
+ const decision = await resolveIMessageInboundDecision({
+ cfg: {} as OpenClawConfig,
+ accountId: "default",
+ message: {
+ id: 12346,
+ guid: "p:0/GUID-current-history",
+ sender: "+15555550123",
+ text: "current",
+ is_from_me: false,
+ is_group: false,
+ },
+ opts: undefined,
+ messageText: "current",
+ bodyText: "current",
+ allowFrom: ["*"],
+ groupAllowFrom: [],
+ groupPolicy: "open",
+ dmPolicy: "open",
+ storeAllowFrom: [],
+ historyLimit: 0,
+ groupHistories: new Map(),
+ echoCache: undefined,
+ selfChatCache: undefined,
+ logVerbose: undefined,
+ });
+ expect(decision.kind).toBe("dispatch");
+ if (decision.kind !== "dispatch") {
+ return;
+ }
+
+ const { ctxPayload, inboundHistory } = buildIMessageInboundContext({
+ cfg: {} as OpenClawConfig,
+ decision,
+ message: {
+ id: 12346,
+ guid: "p:0/GUID-current-history",
+ sender: "+15555550123",
+ text: "current",
+ is_from_me: false,
+ is_group: false,
+ },
+ historyLimit: 0,
+ groupHistories: new Map(),
+ dmHistory: {
+ body: "[iMessage from +15555550123]\nprevious\n[/iMessage]",
+ inboundHistory: [{ sender: "+15555550123", body: "previous" }],
+ },
+ });
+
+ expect(ctxPayload.Body).toContain("previous");
+ expect(ctxPayload.Body).toContain("current");
+ expect(ctxPayload.InboundHistory).toEqual([{ sender: "+15555550123", body: "previous" }]);
+ expect(inboundHistory).toEqual([{ sender: "+15555550123", body: "previous" }]);
+ });
});
describe("resolveIMessageInboundDecision command auth", () => {
diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts
index e8c6910ecc5..1dea9e5cc33 100644
--- a/extensions/imessage/src/monitor/inbound-processing.ts
+++ b/extensions/imessage/src/monitor/inbound-processing.ts
@@ -38,6 +38,7 @@ import {
normalizeIMessageHandle,
parseIMessageAllowTarget,
} from "../targets.js";
+import type { IMessageDmHistoryContext } from "./dm-history.js";
import {
type IMessageReactionContext,
resolveIMessageReactionContext,
@@ -808,6 +809,7 @@ export function buildIMessageInboundContext(params: {
};
historyLimit: number;
groupHistories: Map;
+ dmHistory?: IMessageDmHistoryContext;
}): {
ctxPayload: ReturnType;
fromLabel: string;
@@ -867,6 +869,9 @@ export function buildIMessageInboundContext(params: {
});
let combinedBody = body;
+ if (!decision.isGroup && params.dmHistory?.body) {
+ combinedBody = `${params.dmHistory.body}\n\n${combinedBody}`;
+ }
if (decision.isGroup && decision.historyKey) {
const channelHistory = createChannelHistoryWindow({ historyMap: params.groupHistories });
combinedBody = channelHistory.buildPendingContext({
@@ -888,12 +893,14 @@ export function buildIMessageInboundContext(params: {
const imessageTo = (decision.isGroup ? chatTarget : undefined) || `imessage:${decision.sender}`;
const inboundHistory =
- decision.isGroup && decision.historyKey && params.historyLimit > 0
- ? createChannelHistoryWindow({ historyMap: params.groupHistories }).buildInboundHistory({
- historyKey: decision.historyKey,
- limit: params.historyLimit,
- })
- : undefined;
+ !decision.isGroup && params.dmHistory?.inboundHistory
+ ? params.dmHistory.inboundHistory
+ : decision.isGroup && decision.historyKey && params.historyLimit > 0
+ ? createChannelHistoryWindow({ historyMap: params.groupHistories }).buildInboundHistory({
+ historyKey: decision.historyKey,
+ limit: params.historyLimit,
+ })
+ : undefined;
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts
index 8780b2ffcfc..28f9af3429c 100644
--- a/extensions/imessage/src/monitor/monitor-provider.ts
+++ b/extensions/imessage/src/monitor/monitor-provider.ts
@@ -4,6 +4,7 @@ import { CHANNEL_APPROVAL_NATIVE_RUNTIME_CONTEXT_CAPABILITY } from "openclaw/plu
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import {
createChannelInboundDebouncer,
+ resolveEnvelopeFormatOptions,
shouldDebounceTextInbound,
} from "openclaw/plugin-sdk/channel-inbound";
import {
@@ -59,6 +60,7 @@ import { advanceIMessageCatchupCursor, resolveCatchupConfig } from "./catchup.js
import { combineIMessagePayloads } from "./coalesce.js";
import { repairIMessageConversationAnchor } from "./conversation-repair.js";
import { createIMessageEchoCachingSend, deliverReplies } from "./deliver.js";
+import { resolveIMessageDmHistoryContext, resolveIMessageDmHistoryLimit } from "./dm-history.js";
import { createSentMessageCache } from "./echo-cache.js";
import {
warnGroupAllowlistDropPerChatOnce,
@@ -631,6 +633,24 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
storePath,
sessionKey: decision.route.sessionKey,
});
+ const dmHistoryLimit = !decision.isGroup
+ ? resolveIMessageDmHistoryLimit({
+ config: imessageCfg,
+ sender: decision.sender,
+ senderNormalized: decision.senderNormalized,
+ })
+ : 0;
+ const dmHistory =
+ !decision.isGroup && dmHistoryLimit > 0 && !previousTimestamp
+ ? await resolveIMessageDmHistoryContext({
+ client: getActiveClient(),
+ message,
+ senderNormalized: decision.senderNormalized,
+ limit: dmHistoryLimit,
+ envelopeOptions: resolveEnvelopeFormatOptions(cfg),
+ logVerbose,
+ })
+ : undefined;
const { ctxPayload, chatTarget } = buildIMessageInboundContext({
cfg,
decision,
@@ -639,6 +659,7 @@ export async function monitorIMessageProvider(opts: MonitorIMessageOpts = {}): P
remoteHost,
historyLimit,
groupHistories,
+ dmHistory,
media: {
path: mediaPath,
type: mediaType,