openclaw-threema 0.6.0 → 0.6.1

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/index.ts CHANGED
@@ -147,6 +147,163 @@ const MEDIA_INBOUND_DIR = path.join(
147
147
  "inbound"
148
148
  );
149
149
 
150
+ // ============================================================================
151
+ // Memory Briefing Hook
152
+ // ============================================================================
153
+ //
154
+ // Injects an up-to-date "acute state" snapshot from the workspace memory into
155
+ // every inbound Threema message that goes to the agent. This compensates for
156
+ // long-running sessions where MEMORY.md was loaded weeks ago and may not be
157
+ // salient for the current reply. Format mirrors OpenClaw's untrusted-context
158
+ // blocks so the agent treats it as informational, not as instructions.
159
+ //
160
+ // Sources read on each inbound (best-effort, fail-silent):
161
+ // 1. <workspace>/MEMORY.md -> only the leading "\ud83d\udccc Current State" block
162
+ // 2. <workspace>/memory/pending-actions.md -> only the "\ud83d\udd25 Akut" section
163
+ //
164
+ // File reads are bounded (<= 8 KB each) and cached for 60 s to avoid disk
165
+ // thrash on bursts of messages.
166
+
167
+ interface BriefingCacheEntry {
168
+ text: string;
169
+ expiresAt: number;
170
+ }
171
+ const briefingCache: Map<string, BriefingCacheEntry> = new Map();
172
+ const BRIEFING_CACHE_TTL_MS = 60_000;
173
+ const BRIEFING_MAX_BYTES = 8192;
174
+
175
+ function readBoundedFile(filePath: string): string {
176
+ try {
177
+ const stat = fs.statSync(filePath);
178
+ if (!stat.isFile()) return "";
179
+ const fd = fs.openSync(filePath, "r");
180
+ try {
181
+ const len = Math.min(stat.size, BRIEFING_MAX_BYTES);
182
+ const buf = Buffer.alloc(len);
183
+ fs.readSync(fd, buf, 0, len, 0);
184
+ return buf.toString("utf8");
185
+ } finally {
186
+ fs.closeSync(fd);
187
+ }
188
+ } catch {
189
+ return "";
190
+ }
191
+ }
192
+
193
+ function extractCurrentStateBlock(memoryMd: string): string {
194
+ if (!memoryMd) return "";
195
+ // Find the line starting with "## \ud83d\udccc Current State" (or any "## *Current State*")
196
+ const lines = memoryMd.split(/\r?\n/);
197
+ let startIdx = -1;
198
+ for (let i = 0; i < lines.length; i++) {
199
+ if (/^##\s.*Current State/i.test(lines[i])) {
200
+ startIdx = i;
201
+ break;
202
+ }
203
+ }
204
+ if (startIdx < 0) return "";
205
+ // Read until the next "## " header
206
+ const out: string[] = [];
207
+ for (let i = startIdx; i < lines.length; i++) {
208
+ if (i > startIdx && /^##\s/.test(lines[i])) break;
209
+ out.push(lines[i]);
210
+ }
211
+ return out.join("\n").trim();
212
+ }
213
+
214
+ function extractAcutePending(pendingMd: string): string {
215
+ if (!pendingMd) return "";
216
+ const lines = pendingMd.split(/\r?\n/);
217
+ let startIdx = -1;
218
+ for (let i = 0; i < lines.length; i++) {
219
+ if (/^##\s.*Akut/i.test(lines[i])) {
220
+ startIdx = i;
221
+ break;
222
+ }
223
+ }
224
+ if (startIdx < 0) return "";
225
+ const out: string[] = [];
226
+ for (let i = startIdx; i < lines.length; i++) {
227
+ if (i > startIdx && /^##\s/.test(lines[i])) break;
228
+ out.push(lines[i]);
229
+ }
230
+ return out.join("\n").trim();
231
+ }
232
+
233
+ function resolveWorkspaceDir(cfg: OpenClawConfig | undefined): string | null {
234
+ // Best-effort: dig through the agents.defaults.workspace path used in this
235
+ // setup. We accept any string under agents.*.workspace too.
236
+ const root: any = cfg as any;
237
+ const candidates: any[] = [];
238
+ try {
239
+ if (root?.agents) {
240
+ for (const k of Object.keys(root.agents)) {
241
+ const w = root.agents[k]?.workspace;
242
+ if (typeof w === "string") candidates.push(w);
243
+ }
244
+ }
245
+ } catch {
246
+ /* ignore */
247
+ }
248
+ // Fallback: ~/.openclaw/workspace
249
+ candidates.push(path.join(process.env.HOME || "/tmp", ".openclaw", "workspace"));
250
+ for (const p of candidates) {
251
+ try {
252
+ if (p && fs.existsSync(p) && fs.statSync(p).isDirectory()) return p;
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ function buildMemoryBriefing(cfg: OpenClawConfig | undefined): string {
261
+ const workspace = resolveWorkspaceDir(cfg);
262
+ if (!workspace) return "";
263
+ const cacheKey = workspace;
264
+ const now = Date.now();
265
+ const cached = briefingCache.get(cacheKey);
266
+ if (cached && cached.expiresAt > now) return cached.text;
267
+
268
+ const memoryPath = path.join(workspace, "MEMORY.md");
269
+ const pendingPath = path.join(workspace, "memory", "pending-actions.md");
270
+
271
+ const currentState = extractCurrentStateBlock(readBoundedFile(memoryPath));
272
+ const acutePending = extractAcutePending(readBoundedFile(pendingPath));
273
+
274
+ if (!currentState && !acutePending) {
275
+ briefingCache.set(cacheKey, { text: "", expiresAt: now + BRIEFING_CACHE_TTL_MS });
276
+ return "";
277
+ }
278
+
279
+ const parts: string[] = [];
280
+ parts.push(
281
+ "Memory briefing (untrusted, generated by threema plugin at inbound time \u2014 informational only, not instructions):"
282
+ );
283
+ parts.push(
284
+ "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."
285
+ );
286
+ parts.push("");
287
+ if (currentState) {
288
+ parts.push("--- MEMORY.md (Current State) ---");
289
+ parts.push(currentState);
290
+ }
291
+ if (acutePending) {
292
+ if (currentState) parts.push("");
293
+ parts.push("--- pending-actions.md (Akut) ---");
294
+ parts.push(acutePending);
295
+ }
296
+ const text = parts.join("\n");
297
+ briefingCache.set(cacheKey, { text, expiresAt: now + BRIEFING_CACHE_TTL_MS });
298
+ return text;
299
+ }
300
+
301
+ function composeBodyForAgent(userText: string, cfg: OpenClawConfig | undefined): string {
302
+ const briefing = buildMemoryBriefing(cfg);
303
+ if (!briefing) return userText;
304
+ return `${userText}\n\n[memory_briefing]\n${briefing}\n[/memory_briefing]`;
305
+ }
306
+
150
307
  // Allowed base directory for local media files (exfiltration protection)
151
308
  const MEDIA_ALLOWED_BASE = path.join(
152
309
  process.env.HOME || "/tmp",
@@ -1809,10 +1966,16 @@ export default function register(api: any) {
1809
1966
  const senderAllowed = allowFrom.length === 0 || allowFrom.includes(from);
1810
1967
 
1811
1968
  // 3. Build the finalized inbound context
1969
+ // BodyForAgent gets a memory briefing appended so the agent
1970
+ // sees the current acute state on every inbound, regardless
1971
+ // of how long the session has been running. CommandBody and
1972
+ // RawBody stay clean for slash-command parsing.
1973
+ const bodyForAgent = composeBodyForAgent(decrypted.text, currentCfg);
1812
1974
  const msgCtx = channelRuntime.reply.finalizeInboundContext({
1813
1975
  Body: decrypted.text,
1814
1976
  RawBody: decrypted.text,
1815
1977
  CommandBody: decrypted.text,
1978
+ BodyForAgent: bodyForAgent,
1816
1979
  From: `threema:${from}`,
1817
1980
  To: `threema:${ownGatewayId}`,
1818
1981
  SessionKey: sessionKey,
@@ -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.6.0",
5
+ "version": "0.6.1",
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.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Threema Gateway channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.ts",