openclaw-threema 0.7.1 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.4 (2026-07-05)
4
+
5
+ ### Fixed
6
+
7
+ - **Outbound image/file media in normal agent replies now works** (the standard
8
+ `MEDIA:` directive). Previously the reply `deliver` handlers only had branches
9
+ for audio-as-voice and text — any image/file attachment fell through into the
10
+ text path and was silently dropped (no blob upload, no error). The agent could
11
+ send a picture and the user would receive only the caption text, if anything.
12
+ - Both `deliver` handlers (E2E + fallback pipeline) now resolve outbound media the
13
+ way core supplies it: prefer `payload.mediaUrls` (array) and fall back to the
14
+ legacy `payload.mediaUrl` (string), matching core's `resolveOutboundMediaUrls`.
15
+ Non-audio media is sent via the existing `outbound.sendMedia` pipeline (which
16
+ already handles local-path/`MEDIA_ALLOWED_BASE` validation + blob upload), with
17
+ the reply text ridden along as the caption. If every media send fails, it falls
18
+ back to sending the text so the reply is never lost.
19
+ - Note: outbound media must still live under `~/.openclaw/media/` (exfiltration
20
+ guard, `MEDIA_ALLOWED_BASE`). Paths outside are rejected by design.
21
+
22
+ ## 0.7.3 (2026-06-26)
23
+
24
+ ### Changed
25
+
26
+ - **Memory-briefing now uses "Variante C" (decided by aza 2026-06-21).** Previously
27
+ the full memory snapshot (Current State + entire Akut section) was appended to
28
+ *every* inbound Threema message — which read like an unsolicited status card and
29
+ burned tokens. New behavior:
30
+ - **Short briefing on every inbound:** just 1-2 core lines — live `openclaw --version`
31
+ plus the single most-acute pending one-liner.
32
+ - **Full snapshot only after an idle gap (> 2 h)** per session, so the agent
33
+ re-orients after longer pauses without spamming the full block each turn.
34
+ - **OpenClaw version is pulled LIVE** via `openclaw --version` (10-min cache),
35
+ instead of a static line copied from MEMORY.md — fixes the "briefing shows a
36
+ stale/wrong version" annoyance.
37
+ - Per-session idle tracking (`lastInboundBySession`), short/full briefings cached
38
+ 60 s per workspace, version cached 10 min. Fail-silent throughout; CommandBody /
39
+ RawBody stay clean for slash-command parsing.
40
+
41
+ ## 0.7.2 (2026-06-17)
42
+
43
+ ### Fixed
44
+
45
+ - Added `actions` adapter (`describeMessageTool` / `supportsAction` / `handleAction`)
46
+ so the shared `message` tool's `action=send` works on Threema. Previously core
47
+ threw `Channel threema does not support action send`, which also broke internal
48
+ flows such as the post-restart "interrupted main session recovery" notice.
49
+ Text + media sends reuse the existing `ThreemaClient` / `outbound.sendMedia`
50
+ pipeline (incl. SSRF/local-path validation). Accepts `to`/`target`/`chatId`
51
+ and `message`/`text`/`caption` plus `media`/`mediaUrl`/`filePath`/`path` aliases.
52
+ - Corrected stale `export const version` (was `0.6.7`) to track package version.
53
+
3
54
  ## 0.7.1 (2026-05-09)
4
55
 
5
56
  ### Fixed
package/dist/index.js CHANGED
@@ -22,6 +22,74 @@ const MEDIA_INBOUND_DIR = path.join(process.env.HOME || "/tmp", ".openclaw", "me
22
22
  const briefingCache = new Map();
23
23
  const BRIEFING_CACHE_TTL_MS = 60_000;
24
24
  const BRIEFING_MAX_BYTES = 8192;
25
+ // --- Variante C state (decided by aza 2026-06-21) ---
26
+ // Short briefing on every inbound (1-2 core lines: live OC version + most acute
27
+ // pending line). Full block ONLY once after a session gap > threshold.
28
+ const BRIEFING_FULL_GAP_MS = 2 * 60 * 60 * 1000; // 2 h
29
+ const lastInboundBySession = new Map();
30
+ // Live OpenClaw version, cached for a while (cheap spawn, but avoid per-msg cost).
31
+ let ocVersionCache = { value: "", expiresAt: 0 };
32
+ const OC_VERSION_TTL_MS = 10 * 60 * 1000; // 10 min
33
+ function getLiveOpenClawVersion() {
34
+ const now = Date.now();
35
+ if (ocVersionCache.value && ocVersionCache.expiresAt > now)
36
+ return ocVersionCache.value;
37
+ let version = "";
38
+ try {
39
+ const res = spawnSync("openclaw", ["--version"], { timeout: 4000, encoding: "utf8" });
40
+ if (res.status === 0 && typeof res.stdout === "string") {
41
+ // Output may be like "openclaw 2026.6.9" or just "2026.6.9"
42
+ const m = res.stdout.match(/\d{4}\.\d+\.\d+/);
43
+ version = m ? m[0] : res.stdout.trim().split(/\s+/).pop() || "";
44
+ }
45
+ }
46
+ catch {
47
+ /* fail-silent */
48
+ }
49
+ ocVersionCache = { value: version, expiresAt: now + OC_VERSION_TTL_MS };
50
+ return version;
51
+ }
52
+ // Extract the single most-acute pending item as a short one-liner.
53
+ // The "Akut" section is organized as "### <Topic>" subsections; the first
54
+ // non-done (### that is not ~~struck~~ / not marked ERLEDIGT) topic title is
55
+ // the most useful single line. Falls back to the first content bullet.
56
+ function extractTopPendingLine(acutePending) {
57
+ if (!acutePending)
58
+ return "";
59
+ const lines = acutePending.split(/\r?\n/);
60
+ let firstContent = "";
61
+ for (let i = 0; i < lines.length; i++) {
62
+ const trimmed = lines[i].trim();
63
+ if (!trimmed)
64
+ continue;
65
+ if (/^##\s/.test(trimmed))
66
+ continue; // the "## Akut" header itself
67
+ if (/^_/.test(trimmed))
68
+ continue; // italic meta lines
69
+ // Prefer a "### Topic" subsection title that is not done/struck-through.
70
+ if (/^#{3,6}\s/.test(trimmed)) {
71
+ const title = trimmed.replace(/^#{3,6}\s+/, "").trim();
72
+ const isDone = /~~|ERLEDIGT|ABGESCHLOSSEN|\u2705/i.test(title);
73
+ if (!isDone) {
74
+ let clean = title.replace(/~~/g, "").replace(/\*\*/g, "").trim();
75
+ if (clean.length > 160)
76
+ clean = clean.slice(0, 157) + "...";
77
+ if (clean)
78
+ return clean;
79
+ }
80
+ continue;
81
+ }
82
+ if (/^[-*]\s*$/.test(trimmed))
83
+ continue; // empty bullets
84
+ if (!firstContent) {
85
+ let clean = trimmed.replace(/^[-*]\s+/, "").replace(/~~/g, "").replace(/\*\*/g, "").trim();
86
+ if (clean.length > 160)
87
+ clean = clean.slice(0, 157) + "...";
88
+ firstContent = clean;
89
+ }
90
+ }
91
+ return firstContent;
92
+ }
25
93
  function readBoundedFile(filePath) {
26
94
  try {
27
95
  const stat = fs.statSync(filePath);
@@ -116,11 +184,13 @@ function resolveWorkspaceDir(cfg) {
116
184
  }
117
185
  return null;
118
186
  }
119
- function buildMemoryBriefing(cfg) {
187
+ // Build the FULL memory briefing block (Current State + full Akut section).
188
+ // Sent only once after a session gap (Variante C). Cached 60 s per workspace.
189
+ function buildFullMemoryBriefing(cfg) {
120
190
  const workspace = resolveWorkspaceDir(cfg);
121
191
  if (!workspace)
122
192
  return "";
123
- const cacheKey = workspace;
193
+ const cacheKey = `full:${workspace}`;
124
194
  const now = Date.now();
125
195
  const cached = briefingCache.get(cacheKey);
126
196
  if (cached && cached.expiresAt > now)
@@ -129,14 +199,20 @@ function buildMemoryBriefing(cfg) {
129
199
  const pendingPath = path.join(workspace, "memory", "pending-actions.md");
130
200
  const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
131
201
  const acutePending = extractAcutePending(readBoundedFile(pendingPath));
132
- if (!currentState && !acutePending) {
202
+ const ocVersion = getLiveOpenClawVersion();
203
+ if (!currentState && !acutePending && !ocVersion) {
133
204
  briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
134
205
  return "";
135
206
  }
136
207
  const parts = [];
137
208
  parts.push("Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):");
138
- parts.push("This snapshot is appended to every inbound Threema message so the agent has a fresh view of the user's acute state, even in long-running sessions. Read it before replying.");
209
+ parts.push("Full snapshot \u2014 sent because this session was idle for a while. Read it before replying to re-orient on the user's acute state.");
139
210
  parts.push("");
211
+ if (ocVersion) {
212
+ parts.push(`--- live state ---`);
213
+ parts.push(`OpenClaw version (live \`openclaw --version\`): ${ocVersion}`);
214
+ parts.push("");
215
+ }
140
216
  if (currentState) {
141
217
  parts.push("--- MEMORY.md (Current State) ---");
142
218
  parts.push(currentState);
@@ -151,8 +227,57 @@ function buildMemoryBriefing(cfg) {
151
227
  briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
152
228
  return text;
153
229
  }
154
- function composeBodyForAgent(userText, cfg) {
155
- const briefing = buildMemoryBriefing(cfg);
230
+ // Build the SHORT memory briefing (1-2 core lines). Sent on every inbound.
231
+ // Line 1: live OpenClaw version. Line 2: most acute pending one-liner.
232
+ function buildShortMemoryBriefing(cfg) {
233
+ const workspace = resolveWorkspaceDir(cfg);
234
+ if (!workspace)
235
+ return "";
236
+ const cacheKey = `short:${workspace}`;
237
+ const now = Date.now();
238
+ const cached = briefingCache.get(cacheKey);
239
+ if (cached && cached.expiresAt > now)
240
+ return cached.text;
241
+ const pendingPath = path.join(workspace, "memory", "pending-actions.md");
242
+ const acutePending = extractAcutePending(readBoundedFile(pendingPath));
243
+ const topPending = extractTopPendingLine(acutePending);
244
+ const ocVersion = getLiveOpenClawVersion();
245
+ const coreLines = [];
246
+ if (ocVersion)
247
+ coreLines.push(`OpenClaw: ${ocVersion} (live)`);
248
+ if (topPending)
249
+ coreLines.push(`Akutestes Pending: ${topPending}`);
250
+ if (coreLines.length === 0) {
251
+ briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
252
+ return "";
253
+ }
254
+ const parts = [];
255
+ parts.push("Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions). Short snapshot; full state lives in MEMORY.md / pending-actions.md:");
256
+ parts.push(...coreLines);
257
+ const text = parts.join("\n");
258
+ briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
259
+ return text;
260
+ }
261
+ // Variante C dispatcher: decide short vs. full per session based on idle gap,
262
+ // and update the session's last-inbound timestamp.
263
+ function buildMemoryBriefingForSession(cfg, sessionKey) {
264
+ const now = Date.now();
265
+ const key = sessionKey || "__default__";
266
+ const last = lastInboundBySession.get(key);
267
+ lastInboundBySession.set(key, now);
268
+ // Cap map growth (defensive; one DM peer per session in practice).
269
+ if (lastInboundBySession.size > 256) {
270
+ const cutoff = now - 24 * 60 * 60 * 1000;
271
+ for (const [k, v] of lastInboundBySession) {
272
+ if (v < cutoff)
273
+ lastInboundBySession.delete(k);
274
+ }
275
+ }
276
+ const isFirstOrStale = last === undefined || now - last > BRIEFING_FULL_GAP_MS;
277
+ return isFirstOrStale ? buildFullMemoryBriefing(cfg) : buildShortMemoryBriefing(cfg);
278
+ }
279
+ function composeBodyForAgent(userText, cfg, sessionKey) {
280
+ const briefing = buildMemoryBriefingForSession(cfg, sessionKey);
156
281
  if (!briefing)
157
282
  return userText;
158
283
  return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
@@ -1513,6 +1638,124 @@ const threemaChannel = {
1513
1638
  });
1514
1639
  },
1515
1640
  },
1641
+ // ============================================================================
1642
+ // Message-Tool Action Adapter — powers the shared `message` tool
1643
+ // (action=send) and internal flows like restart-recovery notices.
1644
+ // Without this, core throws "Channel threema does not support action send".
1645
+ // Normal agent replies go through `outbound.sendText`; this is the
1646
+ // explicit/proactive send surface.
1647
+ // ============================================================================
1648
+ actions: {
1649
+ describeMessageTool: (_params) => {
1650
+ return {
1651
+ actions: ["send"],
1652
+ capabilities: [],
1653
+ };
1654
+ },
1655
+ supportsAction: ({ action }) => action === "send",
1656
+ handleAction: async (ctx) => {
1657
+ const { action, params, cfg } = ctx;
1658
+ if (action !== "send") {
1659
+ return {
1660
+ isError: true,
1661
+ content: [
1662
+ { type: "text", text: `Threema: unsupported action "${action}".` },
1663
+ ],
1664
+ };
1665
+ }
1666
+ const threemaCfg = getThreemaConfig(cfg);
1667
+ if (!threemaCfg?.gatewayId || !threemaCfg?.secretKey) {
1668
+ return {
1669
+ isError: true,
1670
+ content: [
1671
+ { type: "text", text: "Threema not configured: missing gatewayId or secretKey." },
1672
+ ],
1673
+ };
1674
+ }
1675
+ // Resolve destination from common param aliases.
1676
+ const rawTo = (typeof params?.to === "string" && params.to) ||
1677
+ (typeof params?.target === "string" && params.target) ||
1678
+ (typeof params?.chatId === "string" && params.chatId) ||
1679
+ "";
1680
+ if (!rawTo) {
1681
+ return {
1682
+ isError: true,
1683
+ content: [{ type: "text", text: "Threema send: missing 'to'/'target'." }],
1684
+ };
1685
+ }
1686
+ const to = normalizeThreemaTarget(rawTo);
1687
+ if (!isValidThreemaId(to)) {
1688
+ return {
1689
+ isError: true,
1690
+ content: [{ type: "text", text: `Threema send: invalid Threema ID "${rawTo}".` }],
1691
+ };
1692
+ }
1693
+ const mediaUrl = (typeof params?.media === "string" && params.media) ||
1694
+ (typeof params?.mediaUrl === "string" && params.mediaUrl) ||
1695
+ (typeof params?.filePath === "string" && params.filePath) ||
1696
+ (typeof params?.path === "string" && params.path) ||
1697
+ "";
1698
+ const text = (typeof params?.message === "string" && params.message) ||
1699
+ (typeof params?.text === "string" && params.text) ||
1700
+ (typeof params?.caption === "string" && params.caption) ||
1701
+ "";
1702
+ if (!text && !mediaUrl) {
1703
+ return {
1704
+ isError: true,
1705
+ content: [{ type: "text", text: "Threema send: nothing to send (no message or media)." }],
1706
+ };
1707
+ }
1708
+ try {
1709
+ const client = new ThreemaClient(threemaCfg);
1710
+ // Media path: delegate to the same outbound.sendMedia pipeline so all
1711
+ // SSRF/local-path validation stays in one place.
1712
+ if (mediaUrl) {
1713
+ const result = await threemaChannel.outbound.sendMedia({
1714
+ cfg,
1715
+ to,
1716
+ text,
1717
+ mediaUrl,
1718
+ });
1719
+ return {
1720
+ content: [
1721
+ {
1722
+ type: "text",
1723
+ text: `Threema media sent to ${to} (messageId ${result?.messageId ?? "?"}).`,
1724
+ },
1725
+ ],
1726
+ meta: result,
1727
+ };
1728
+ }
1729
+ // Text path.
1730
+ const formatted = markdownToThreema(text);
1731
+ const messageId = client.isE2EEnabled
1732
+ ? await client.sendE2E(to, formatted)
1733
+ : await client.sendSimple(to, formatted);
1734
+ return {
1735
+ content: [
1736
+ {
1737
+ type: "text",
1738
+ text: `Threema message sent to ${to} (messageId ${messageId.trim()}).`,
1739
+ },
1740
+ ],
1741
+ meta: {
1742
+ channel: "threema",
1743
+ messageId: messageId.trim(),
1744
+ chatId: to,
1745
+ timestamp: Date.now(),
1746
+ },
1747
+ };
1748
+ }
1749
+ catch (err) {
1750
+ return {
1751
+ isError: true,
1752
+ content: [
1753
+ { type: "text", text: `Threema send failed: ${err?.message ?? String(err)}` },
1754
+ ],
1755
+ };
1756
+ }
1757
+ },
1758
+ },
1516
1759
  };
1517
1760
  const COALESCE_WINDOW_MS = 3000; // 3 second coalescing window
1518
1761
  const COALESCE_MAX_PARTS = 5; // Max parts before flushing
@@ -1645,7 +1888,7 @@ async function queueInboundForCoalescing(sender, part, dispatcher) {
1645
1888
  }
1646
1889
  export const id = "threema";
1647
1890
  export const name = "Threema Gateway";
1648
- export const version = "0.6.7";
1891
+ export const version = "0.7.4";
1649
1892
  export const description = "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
1650
1893
  export default function register(api) {
1651
1894
  try {
@@ -1791,7 +2034,11 @@ export default function register(api) {
1791
2034
  const channelRuntime = runtime?.channel;
1792
2035
  if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
1793
2036
  try {
1794
- const currentCfg = runtime.config.loadConfig();
2037
+ // Use the read-only runtime config snapshot. loadConfig() is
2038
+ // deprecated (deprecated-internal-config-api guard) and slated
2039
+ // for removal; current() returns a DeepReadonly<OpenClawConfig>
2040
+ // which we only read here (route resolution + memory briefing).
2041
+ const currentCfg = runtime.config.current();
1795
2042
  // 1. Resolve the agent route and session key
1796
2043
  const route = channelRuntime.routing.resolveAgentRoute({
1797
2044
  cfg: currentCfg,
@@ -1813,7 +2060,7 @@ export default function register(api) {
1813
2060
  // sees the current acute state on every inbound, regardless
1814
2061
  // of how long the session has been running. CommandBody and
1815
2062
  // RawBody stay clean for slash-command parsing.
1816
- const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg);
2063
+ const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg, sessionKey);
1817
2064
  const msgCtx = channelRuntime.reply.finalizeInboundContext({
1818
2065
  Body: merged.mergedText,
1819
2066
  RawBody: merged.mergedText,
@@ -1888,6 +2135,42 @@ export default function register(api) {
1888
2135
  }
1889
2136
  }
1890
2137
  else {
2138
+ // Media path (non-audio): if the reply carries image/file media,
2139
+ // send it as a Threema file message. Core provides media as
2140
+ // `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
2141
+ const mediaUrls = Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
2142
+ ? payload.mediaUrls.filter((u) => typeof u === "string" && u.length > 0)
2143
+ : (typeof payload.mediaUrl === "string" && payload.mediaUrl
2144
+ ? [payload.mediaUrl]
2145
+ : []);
2146
+ if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
2147
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2148
+ let sentAny = false;
2149
+ for (let i = 0; i < mediaUrls.length; i++) {
2150
+ const mu = mediaUrls[i];
2151
+ try {
2152
+ await threemaChannel.outbound.sendMedia({
2153
+ cfg: currentCfg,
2154
+ to: from,
2155
+ text: i === 0 ? (caption ?? "") : "",
2156
+ mediaUrl: mu,
2157
+ });
2158
+ sentAny = true;
2159
+ }
2160
+ catch (mediaErr) {
2161
+ api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
2162
+ }
2163
+ }
2164
+ // If at least one media went out, we're done (caption rode along).
2165
+ // If ALL media failed, fall through to text so the reply isn't lost.
2166
+ if (sentAny)
2167
+ return;
2168
+ const fallbackText = markdownToThreema(payload.text ?? payload.body);
2169
+ if (fallbackText) {
2170
+ await replyClient.sendE2E(from, fallbackText);
2171
+ }
2172
+ return;
2173
+ }
1891
2174
  // Send as text (existing logic)
1892
2175
  const text = markdownToThreema(payload.text ?? payload.body);
1893
2176
  if (!text)
@@ -2017,7 +2300,9 @@ export default function register(api) {
2017
2300
  && channelRuntime?.reply?.finalizeInboundContext
2018
2301
  && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
2019
2302
  try {
2020
- const currentCfg = runtime.config.loadConfig();
2303
+ // Read-only runtime config snapshot (see text path above):
2304
+ // loadConfig() is deprecated and on the removal list.
2305
+ const currentCfg = runtime.config.current();
2021
2306
  // Resolve the same agent route + session key the text path uses
2022
2307
  // so file inbounds end up in the live Threema DM session.
2023
2308
  const route = channelRuntime.routing.resolveAgentRoute({
@@ -2034,7 +2319,7 @@ export default function register(api) {
2034
2319
  dmScope: "per-account-channel-peer",
2035
2320
  });
2036
2321
  const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
2037
- const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
2322
+ const bodyForAgent = composeBodyForAgent(fileBody, currentCfg, sessionKey);
2038
2323
  const fileCtx = channelRuntime.reply.finalizeInboundContext({
2039
2324
  Body: fileBody,
2040
2325
  RawBody: fileBody,
@@ -2112,6 +2397,42 @@ export default function register(api) {
2112
2397
  }
2113
2398
  }
2114
2399
  else {
2400
+ // Media path (non-audio): if the reply carries image/file media,
2401
+ // send it as a Threema file message. Core provides media as
2402
+ // `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
2403
+ const mediaUrls = Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
2404
+ ? payload.mediaUrls.filter((u) => typeof u === "string" && u.length > 0)
2405
+ : (typeof payload.mediaUrl === "string" && payload.mediaUrl
2406
+ ? [payload.mediaUrl]
2407
+ : []);
2408
+ if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
2409
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2410
+ let sentAny = false;
2411
+ for (let i = 0; i < mediaUrls.length; i++) {
2412
+ const mu = mediaUrls[i];
2413
+ try {
2414
+ await threemaChannel.outbound.sendMedia({
2415
+ cfg: currentCfg,
2416
+ to: from,
2417
+ text: i === 0 ? (caption ?? "") : "",
2418
+ mediaUrl: mu,
2419
+ });
2420
+ sentAny = true;
2421
+ }
2422
+ catch (mediaErr) {
2423
+ api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
2424
+ }
2425
+ }
2426
+ // If at least one media went out, we're done (caption rode along).
2427
+ // If ALL media failed, fall through to text so the reply isn't lost.
2428
+ if (sentAny)
2429
+ return;
2430
+ const fallbackText = markdownToThreema(payload.text ?? payload.body);
2431
+ if (fallbackText) {
2432
+ await replyClient.sendE2E(from, fallbackText);
2433
+ }
2434
+ return;
2435
+ }
2115
2436
  // Send as text (existing logic)
2116
2437
  const text = markdownToThreema(payload.text ?? payload.body);
2117
2438
  if (!text)
package/index.ts CHANGED
@@ -174,6 +174,67 @@ const briefingCache: Map<string, BriefingCacheEntry> = new Map();
174
174
  const BRIEFING_CACHE_TTL_MS = 60_000;
175
175
  const BRIEFING_MAX_BYTES = 8192;
176
176
 
177
+ // --- Variante C state (decided by aza 2026-06-21) ---
178
+ // Short briefing on every inbound (1-2 core lines: live OC version + most acute
179
+ // pending line). Full block ONLY once after a session gap > threshold.
180
+ const BRIEFING_FULL_GAP_MS = 2 * 60 * 60 * 1000; // 2 h
181
+ const lastInboundBySession: Map<string, number> = new Map();
182
+ // Live OpenClaw version, cached for a while (cheap spawn, but avoid per-msg cost).
183
+ let ocVersionCache: { value: string; expiresAt: number } = { value: "", expiresAt: 0 };
184
+ const OC_VERSION_TTL_MS = 10 * 60 * 1000; // 10 min
185
+
186
+ function getLiveOpenClawVersion(): string {
187
+ const now = Date.now();
188
+ if (ocVersionCache.value && ocVersionCache.expiresAt > now) return ocVersionCache.value;
189
+ let version = "";
190
+ try {
191
+ const res = spawnSync("openclaw", ["--version"], { timeout: 4000, encoding: "utf8" });
192
+ if (res.status === 0 && typeof res.stdout === "string") {
193
+ // Output may be like "openclaw 2026.6.9" or just "2026.6.9"
194
+ const m = res.stdout.match(/\d{4}\.\d+\.\d+/);
195
+ version = m ? m[0] : res.stdout.trim().split(/\s+/).pop() || "";
196
+ }
197
+ } catch {
198
+ /* fail-silent */
199
+ }
200
+ ocVersionCache = { value: version, expiresAt: now + OC_VERSION_TTL_MS };
201
+ return version;
202
+ }
203
+
204
+ // Extract the single most-acute pending item as a short one-liner.
205
+ // The "Akut" section is organized as "### <Topic>" subsections; the first
206
+ // non-done (### that is not ~~struck~~ / not marked ERLEDIGT) topic title is
207
+ // the most useful single line. Falls back to the first content bullet.
208
+ function extractTopPendingLine(acutePending: string): string {
209
+ if (!acutePending) return "";
210
+ const lines = acutePending.split(/\r?\n/);
211
+ let firstContent = "";
212
+ for (let i = 0; i < lines.length; i++) {
213
+ const trimmed = lines[i].trim();
214
+ if (!trimmed) continue;
215
+ if (/^##\s/.test(trimmed)) continue; // the "## Akut" header itself
216
+ if (/^_/.test(trimmed)) continue; // italic meta lines
217
+ // Prefer a "### Topic" subsection title that is not done/struck-through.
218
+ if (/^#{3,6}\s/.test(trimmed)) {
219
+ const title = trimmed.replace(/^#{3,6}\s+/, "").trim();
220
+ const isDone = /~~|ERLEDIGT|ABGESCHLOSSEN|\u2705/i.test(title);
221
+ if (!isDone) {
222
+ let clean = title.replace(/~~/g, "").replace(/\*\*/g, "").trim();
223
+ if (clean.length > 160) clean = clean.slice(0, 157) + "...";
224
+ if (clean) return clean;
225
+ }
226
+ continue;
227
+ }
228
+ if (/^[-*]\s*$/.test(trimmed)) continue; // empty bullets
229
+ if (!firstContent) {
230
+ let clean = trimmed.replace(/^[-*]\s+/, "").replace(/~~/g, "").replace(/\*\*/g, "").trim();
231
+ if (clean.length > 160) clean = clean.slice(0, 157) + "...";
232
+ firstContent = clean;
233
+ }
234
+ }
235
+ return firstContent;
236
+ }
237
+
177
238
  function readBoundedFile(filePath: string): string {
178
239
  try {
179
240
  const stat = fs.statSync(filePath);
@@ -259,10 +320,12 @@ function resolveWorkspaceDir(cfg: OpenClawConfig | undefined): string | null {
259
320
  return null;
260
321
  }
261
322
 
262
- function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
323
+ // Build the FULL memory briefing block (Current State + full Akut section).
324
+ // Sent only once after a session gap (Variante C). Cached 60 s per workspace.
325
+ function buildFullMemoryBriefing(cfg: OpenClawConfig | undefined): string {
263
326
  const workspace = resolveWorkspaceDir(cfg);
264
327
  if (!workspace) return "";
265
- const cacheKey = workspace;
328
+ const cacheKey = `full:${workspace}`;
266
329
  const now = Date.now();
267
330
  const cached = briefingCache.get(cacheKey);
268
331
  if (cached && cached.expiresAt > now) return cached.text;
@@ -272,8 +335,9 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
272
335
 
273
336
  const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
274
337
  const acutePending = extractAcutePending(readBoundedFile(pendingPath));
338
+ const ocVersion = getLiveOpenClawVersion();
275
339
 
276
- if (!currentState && !acutePending) {
340
+ if (!currentState && !acutePending && !ocVersion) {
277
341
  briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
278
342
  return "";
279
343
  }
@@ -283,9 +347,14 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
283
347
  "Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):"
284
348
  );
285
349
  parts.push(
286
- "This snapshot is appended to every inbound Threema message so the agent has a fresh view of the user's acute state, even in long-running sessions. Read it before replying."
350
+ "Full snapshot \u2014 sent because this session was idle for a while. Read it before replying to re-orient on the user's acute state."
287
351
  );
288
352
  parts.push("");
353
+ if (ocVersion) {
354
+ parts.push(`--- live state ---`);
355
+ parts.push(`OpenClaw version (live \`openclaw --version\`): ${ocVersion}`);
356
+ parts.push("");
357
+ }
289
358
  if (currentState) {
290
359
  parts.push("--- MEMORY.md (Current State) ---");
291
360
  parts.push(currentState);
@@ -300,8 +369,67 @@ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
300
369
  return text;
301
370
  }
302
371
 
303
- function composeBodyForAgent(userText: string, cfg: OpenClawConfig | undefined): string {
304
- const briefing = buildMemoryBriefing(cfg);
372
+ // Build the SHORT memory briefing (1-2 core lines). Sent on every inbound.
373
+ // Line 1: live OpenClaw version. Line 2: most acute pending one-liner.
374
+ function buildShortMemoryBriefing(cfg: OpenClawConfig | undefined): string {
375
+ const workspace = resolveWorkspaceDir(cfg);
376
+ if (!workspace) return "";
377
+ const cacheKey = `short:${workspace}`;
378
+ const now = Date.now();
379
+ const cached = briefingCache.get(cacheKey);
380
+ if (cached && cached.expiresAt > now) return cached.text;
381
+
382
+ const pendingPath = path.join(workspace, "memory", "pending-actions.md");
383
+ const acutePending = extractAcutePending(readBoundedFile(pendingPath));
384
+ const topPending = extractTopPendingLine(acutePending);
385
+ const ocVersion = getLiveOpenClawVersion();
386
+
387
+ const coreLines: string[] = [];
388
+ if (ocVersion) coreLines.push(`OpenClaw: ${ocVersion} (live)`);
389
+ if (topPending) coreLines.push(`Akutestes Pending: ${topPending}`);
390
+
391
+ if (coreLines.length === 0) {
392
+ briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
393
+ return "";
394
+ }
395
+
396
+ const parts: string[] = [];
397
+ parts.push(
398
+ "Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions). Short snapshot; full state lives in MEMORY.md / pending-actions.md:"
399
+ );
400
+ parts.push(...coreLines);
401
+ const text = parts.join("\n");
402
+ briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
403
+ return text;
404
+ }
405
+
406
+ // Variante C dispatcher: decide short vs. full per session based on idle gap,
407
+ // and update the session's last-inbound timestamp.
408
+ function buildMemoryBriefingForSession(
409
+ cfg: OpenClawConfig | undefined,
410
+ sessionKey: string | undefined
411
+ ): string {
412
+ const now = Date.now();
413
+ const key = sessionKey || "__default__";
414
+ const last = lastInboundBySession.get(key);
415
+ lastInboundBySession.set(key, now);
416
+ // Cap map growth (defensive; one DM peer per session in practice).
417
+ if (lastInboundBySession.size > 256) {
418
+ const cutoff = now - 24 * 60 * 60 * 1000;
419
+ for (const [k, v] of lastInboundBySession) {
420
+ if (v < cutoff) lastInboundBySession.delete(k);
421
+ }
422
+ }
423
+ const isFirstOrStale = last === undefined || now - last > BRIEFING_FULL_GAP_MS;
424
+ return isFirstOrStale ? buildFullMemoryBriefing(cfg) : buildShortMemoryBriefing(cfg);
425
+ }
426
+
427
+ function composeBodyForAgent(
428
+ userText: string,
429
+ cfg: OpenClawConfig | undefined,
430
+ sessionKey?: string
431
+ ): string {
432
+ const briefing = buildMemoryBriefingForSession(cfg, sessionKey);
305
433
  if (!briefing) return userText;
306
434
  return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
307
435
  }
@@ -1951,6 +2079,139 @@ const threemaChannel = {
1951
2079
  });
1952
2080
  },
1953
2081
  },
2082
+
2083
+ // ============================================================================
2084
+ // Message-Tool Action Adapter — powers the shared `message` tool
2085
+ // (action=send) and internal flows like restart-recovery notices.
2086
+ // Without this, core throws "Channel threema does not support action send".
2087
+ // Normal agent replies go through `outbound.sendText`; this is the
2088
+ // explicit/proactive send surface.
2089
+ // ============================================================================
2090
+ actions: {
2091
+ describeMessageTool: (_params: any) => {
2092
+ return {
2093
+ actions: ["send"] as const,
2094
+ capabilities: [] as const,
2095
+ };
2096
+ },
2097
+
2098
+ supportsAction: ({ action }: { action: string }): boolean =>
2099
+ action === "send",
2100
+
2101
+ handleAction: async (ctx: any): Promise<any> => {
2102
+ const { action, params, cfg } = ctx;
2103
+
2104
+ if (action !== "send") {
2105
+ return {
2106
+ isError: true,
2107
+ content: [
2108
+ { type: "text", text: `Threema: unsupported action "${action}".` },
2109
+ ],
2110
+ };
2111
+ }
2112
+
2113
+ const threemaCfg = getThreemaConfig(cfg);
2114
+ if (!threemaCfg?.gatewayId || !threemaCfg?.secretKey) {
2115
+ return {
2116
+ isError: true,
2117
+ content: [
2118
+ { type: "text", text: "Threema not configured: missing gatewayId or secretKey." },
2119
+ ],
2120
+ };
2121
+ }
2122
+
2123
+ // Resolve destination from common param aliases.
2124
+ const rawTo =
2125
+ (typeof params?.to === "string" && params.to) ||
2126
+ (typeof params?.target === "string" && params.target) ||
2127
+ (typeof params?.chatId === "string" && params.chatId) ||
2128
+ "";
2129
+ if (!rawTo) {
2130
+ return {
2131
+ isError: true,
2132
+ content: [{ type: "text", text: "Threema send: missing 'to'/'target'." }],
2133
+ };
2134
+ }
2135
+ const to = normalizeThreemaTarget(rawTo);
2136
+ if (!isValidThreemaId(to)) {
2137
+ return {
2138
+ isError: true,
2139
+ content: [{ type: "text", text: `Threema send: invalid Threema ID "${rawTo}".` }],
2140
+ };
2141
+ }
2142
+
2143
+ const mediaUrl =
2144
+ (typeof params?.media === "string" && params.media) ||
2145
+ (typeof params?.mediaUrl === "string" && params.mediaUrl) ||
2146
+ (typeof params?.filePath === "string" && params.filePath) ||
2147
+ (typeof params?.path === "string" && params.path) ||
2148
+ "";
2149
+ const text =
2150
+ (typeof params?.message === "string" && params.message) ||
2151
+ (typeof params?.text === "string" && params.text) ||
2152
+ (typeof params?.caption === "string" && params.caption) ||
2153
+ "";
2154
+
2155
+ if (!text && !mediaUrl) {
2156
+ return {
2157
+ isError: true,
2158
+ content: [{ type: "text", text: "Threema send: nothing to send (no message or media)." }],
2159
+ };
2160
+ }
2161
+
2162
+ try {
2163
+ const client = new ThreemaClient(threemaCfg);
2164
+
2165
+ // Media path: delegate to the same outbound.sendMedia pipeline so all
2166
+ // SSRF/local-path validation stays in one place.
2167
+ if (mediaUrl) {
2168
+ const result = await threemaChannel.outbound.sendMedia({
2169
+ cfg,
2170
+ to,
2171
+ text,
2172
+ mediaUrl,
2173
+ } as any);
2174
+ return {
2175
+ content: [
2176
+ {
2177
+ type: "text",
2178
+ text: `Threema media sent to ${to} (messageId ${(result as any)?.messageId ?? "?"}).`,
2179
+ },
2180
+ ],
2181
+ meta: result,
2182
+ };
2183
+ }
2184
+
2185
+ // Text path.
2186
+ const formatted = markdownToThreema(text);
2187
+ const messageId = client.isE2EEnabled
2188
+ ? await client.sendE2E(to, formatted)
2189
+ : await client.sendSimple(to, formatted);
2190
+
2191
+ return {
2192
+ content: [
2193
+ {
2194
+ type: "text",
2195
+ text: `Threema message sent to ${to} (messageId ${messageId.trim()}).`,
2196
+ },
2197
+ ],
2198
+ meta: {
2199
+ channel: "threema",
2200
+ messageId: messageId.trim(),
2201
+ chatId: to,
2202
+ timestamp: Date.now(),
2203
+ },
2204
+ };
2205
+ } catch (err: any) {
2206
+ return {
2207
+ isError: true,
2208
+ content: [
2209
+ { type: "text", text: `Threema send failed: ${err?.message ?? String(err)}` },
2210
+ ],
2211
+ };
2212
+ }
2213
+ },
2214
+ },
1954
2215
  };
1955
2216
 
1956
2217
  // ============================================================================
@@ -2122,7 +2383,7 @@ async function queueInboundForCoalescing(
2122
2383
 
2123
2384
  export const id = "threema";
2124
2385
  export const name = "Threema Gateway";
2125
- export const version = "0.6.7";
2386
+ export const version = "0.7.4";
2126
2387
  export const description =
2127
2388
  "Threema messaging channel via Threema Gateway API (E2E encrypted, with media support)";
2128
2389
 
@@ -2303,7 +2564,11 @@ export default function register(api: any) {
2303
2564
  const channelRuntime = runtime?.channel;
2304
2565
  if (channelRuntime?.routing?.resolveAgentRoute && channelRuntime?.reply?.finalizeInboundContext && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
2305
2566
  try {
2306
- const currentCfg = runtime.config.loadConfig();
2567
+ // Use the read-only runtime config snapshot. loadConfig() is
2568
+ // deprecated (deprecated-internal-config-api guard) and slated
2569
+ // for removal; current() returns a DeepReadonly<OpenClawConfig>
2570
+ // which we only read here (route resolution + memory briefing).
2571
+ const currentCfg = runtime.config.current() as OpenClawConfig;
2307
2572
 
2308
2573
  // 1. Resolve the agent route and session key
2309
2574
  const route = channelRuntime.routing.resolveAgentRoute({
@@ -2329,7 +2594,7 @@ export default function register(api: any) {
2329
2594
  // sees the current acute state on every inbound, regardless
2330
2595
  // of how long the session has been running. CommandBody and
2331
2596
  // RawBody stay clean for slash-command parsing.
2332
- const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg);
2597
+ const bodyForAgent = composeBodyForAgent(merged.mergedText, currentCfg, sessionKey);
2333
2598
  const msgCtx = channelRuntime.reply.finalizeInboundContext({
2334
2599
  Body: merged.mergedText,
2335
2600
  RawBody: merged.mergedText,
@@ -2401,6 +2666,41 @@ export default function register(api: any) {
2401
2666
  }
2402
2667
  }
2403
2668
  } else {
2669
+ // Media path (non-audio): if the reply carries image/file media,
2670
+ // send it as a Threema file message. Core provides media as
2671
+ // `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
2672
+ const mediaUrls: string[] =
2673
+ Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
2674
+ ? payload.mediaUrls.filter((u: any) => typeof u === "string" && u.length > 0)
2675
+ : (typeof payload.mediaUrl === "string" && payload.mediaUrl
2676
+ ? [payload.mediaUrl]
2677
+ : []);
2678
+ if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
2679
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2680
+ let sentAny = false;
2681
+ for (let i = 0; i < mediaUrls.length; i++) {
2682
+ const mu = mediaUrls[i];
2683
+ try {
2684
+ await threemaChannel.outbound.sendMedia({
2685
+ cfg: currentCfg,
2686
+ to: from,
2687
+ text: i === 0 ? (caption ?? "") : "",
2688
+ mediaUrl: mu,
2689
+ });
2690
+ sentAny = true;
2691
+ } catch (mediaErr: any) {
2692
+ api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
2693
+ }
2694
+ }
2695
+ // If at least one media went out, we're done (caption rode along).
2696
+ // If ALL media failed, fall through to text so the reply isn't lost.
2697
+ if (sentAny) return;
2698
+ const fallbackText = markdownToThreema(payload.text ?? payload.body);
2699
+ if (fallbackText) {
2700
+ await replyClient.sendE2E(from, fallbackText);
2701
+ }
2702
+ return;
2703
+ }
2404
2704
  // Send as text (existing logic)
2405
2705
  const text = markdownToThreema(payload.text ?? payload.body);
2406
2706
  if (!text) return;
@@ -2533,7 +2833,9 @@ export default function register(api: any) {
2533
2833
  && channelRuntime?.reply?.finalizeInboundContext
2534
2834
  && channelRuntime?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
2535
2835
  try {
2536
- const currentCfg = runtime.config.loadConfig();
2836
+ // Read-only runtime config snapshot (see text path above):
2837
+ // loadConfig() is deprecated and on the removal list.
2838
+ const currentCfg = runtime.config.current() as OpenClawConfig;
2537
2839
 
2538
2840
  // Resolve the same agent route + session key the text path uses
2539
2841
  // so file inbounds end up in the live Threema DM session.
@@ -2554,7 +2856,7 @@ export default function register(api: any) {
2554
2856
 
2555
2857
  const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
2556
2858
 
2557
- const bodyForAgent = composeBodyForAgent(fileBody, currentCfg);
2859
+ const bodyForAgent = composeBodyForAgent(fileBody, currentCfg, sessionKey);
2558
2860
  const fileCtx = channelRuntime.reply.finalizeInboundContext({
2559
2861
  Body: fileBody,
2560
2862
  RawBody: fileBody,
@@ -2629,6 +2931,41 @@ export default function register(api: any) {
2629
2931
  }
2630
2932
  }
2631
2933
  } else {
2934
+ // Media path (non-audio): if the reply carries image/file media,
2935
+ // send it as a Threema file message. Core provides media as
2936
+ // `mediaUrls` (array, preferred) or legacy `mediaUrl` (string).
2937
+ const mediaUrls: string[] =
2938
+ Array.isArray(payload.mediaUrls) && payload.mediaUrls.length > 0
2939
+ ? payload.mediaUrls.filter((u: any) => typeof u === "string" && u.length > 0)
2940
+ : (typeof payload.mediaUrl === "string" && payload.mediaUrl
2941
+ ? [payload.mediaUrl]
2942
+ : []);
2943
+ if (mediaUrls.length > 0 && replyClient.isE2EEnabled) {
2944
+ const caption = markdownToThreema(payload.text ?? payload.body) || undefined;
2945
+ let sentAny = false;
2946
+ for (let i = 0; i < mediaUrls.length; i++) {
2947
+ const mu = mediaUrls[i];
2948
+ try {
2949
+ await threemaChannel.outbound.sendMedia({
2950
+ cfg: currentCfg,
2951
+ to: from,
2952
+ text: i === 0 ? (caption ?? "") : "",
2953
+ mediaUrl: mu,
2954
+ });
2955
+ sentAny = true;
2956
+ } catch (mediaErr: any) {
2957
+ api.logger?.error?.(`Threema: media send failed for ${mu}: ${mediaErr?.message}`);
2958
+ }
2959
+ }
2960
+ // If at least one media went out, we're done (caption rode along).
2961
+ // If ALL media failed, fall through to text so the reply isn't lost.
2962
+ if (sentAny) return;
2963
+ const fallbackText = markdownToThreema(payload.text ?? payload.body);
2964
+ if (fallbackText) {
2965
+ await replyClient.sendE2E(from, fallbackText);
2966
+ }
2967
+ return;
2968
+ }
2632
2969
  // Send as text (existing logic)
2633
2970
  const text = markdownToThreema(payload.text ?? payload.body);
2634
2971
  if (!text) return;
@@ -2,7 +2,7 @@
2
2
  "id": "threema",
3
3
  "name": "Threema Gateway",
4
4
  "description": "Threema messaging channel via Threema Gateway API (E2E encrypted)",
5
- "version": "0.7.1",
5
+ "version": "0.7.4",
6
6
  "channels": [
7
7
  "threema"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-threema",
3
- "version": "0.7.1",
3
+ "version": "0.7.4",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",