adp-openclaw 0.0.55 → 0.0.57

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/monitor.ts +184 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adp-openclaw",
3
- "version": "0.0.55",
3
+ "version": "0.0.57",
4
4
  "description": "ADP-OpenClaw demo channel plugin (Go WebSocket backend)",
5
5
  "type": "module",
6
6
  "dependencies": {
package/src/monitor.ts CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  formatUploadResultAsMarkdown,
24
24
  } from "./tool-result-message-blocks.js";
25
25
  import crypto from "crypto";
26
+ import fs from "fs";
27
+ import path from "path";
26
28
  // @ts-ignore - import JSON file
27
29
  import packageJson from "../package.json" with { type: "json" };
28
30
 
@@ -116,6 +118,167 @@ function generateRequestId(): string {
116
118
  return `req-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
117
119
  }
118
120
 
121
+ /**
122
+ * Mark a session's abortedLastRun flag in the sessions store.
123
+ * This tells the SDK to inject an "abort hint" on the next message,
124
+ * preventing the AI from resuming the cancelled task.
125
+ */
126
+ async function markSessionAborted(params: {
127
+ sessionKey: string;
128
+ runtime: ReturnType<typeof getAdpOpenclawRuntime>;
129
+ cfg?: ClawdbotConfig;
130
+ log?: PluginLogger;
131
+ }): Promise<void> {
132
+ const { sessionKey, runtime, cfg, log } = params;
133
+ try {
134
+ // Use SDK's resolveStorePath to find the sessions.json location
135
+ const storePath = runtime.channel.session.resolveStorePath(cfg?.session?.store);
136
+ if (!storePath || !fs.existsSync(storePath)) {
137
+ log?.warn?.(`[adp-openclaw] Cannot mark session aborted: store not found at ${storePath}`);
138
+ return;
139
+ }
140
+
141
+ const raw = fs.readFileSync(storePath, "utf-8");
142
+ const store = JSON.parse(raw) as Record<string, { abortedLastRun?: boolean; updatedAt?: number; [key: string]: unknown }>;
143
+
144
+ // Try both the raw sessionKey and the "agent:main:{sessionKey}" variant
145
+ const candidates = [sessionKey, `agent:main:${sessionKey}`];
146
+ let matchedKey: string | undefined;
147
+ for (const key of candidates) {
148
+ if (store[key]) {
149
+ matchedKey = key;
150
+ break;
151
+ }
152
+ }
153
+
154
+ if (!matchedKey) {
155
+ log?.info?.(`[adp-openclaw] Session key not found in store for abort marking: ${sessionKey}`);
156
+ return;
157
+ }
158
+
159
+ store[matchedKey].abortedLastRun = true;
160
+ store[matchedKey].updatedAt = Date.now();
161
+ fs.writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
162
+ log?.info?.(`[adp-openclaw] Marked session ${matchedKey} as abortedLastRun=true`);
163
+ } catch (err) {
164
+ log?.error?.(`[adp-openclaw] Failed to mark session aborted: ${err}`);
165
+ }
166
+ }
167
+
168
+ /**
169
+ * After an abort, trim the aborted user message and any partial assistant reply
170
+ * from the JSONL transcript file. This prevents the AI from seeing the cancelled
171
+ * request in its context window and resuming it on the next turn.
172
+ *
173
+ * The JSONL transcript is a tree structure (id + parentId). We find the last
174
+ * user message entry and remove it (plus any subsequent entries like partial
175
+ * assistant replies). This is equivalent to SessionManager.branch(parentId).
176
+ */
177
+ async function trimAbortedMessagesFromTranscript(params: {
178
+ sessionKey: string;
179
+ runtime: ReturnType<typeof getAdpOpenclawRuntime>;
180
+ cfg?: ClawdbotConfig;
181
+ log?: PluginLogger;
182
+ }): Promise<void> {
183
+ const { sessionKey, runtime, cfg, log } = params;
184
+ try {
185
+ const storePath = runtime.channel.session.resolveStorePath(cfg?.session?.store);
186
+ if (!storePath || !fs.existsSync(storePath)) {
187
+ log?.warn?.(`[adp-openclaw] Cannot trim transcript: store not found at ${storePath}`);
188
+ return;
189
+ }
190
+
191
+ const raw = fs.readFileSync(storePath, "utf-8");
192
+ const store = JSON.parse(raw) as Record<string, { sessionId?: string; sessionFile?: string; [key: string]: unknown }>;
193
+
194
+ // Find matching session entry
195
+ const candidates = [sessionKey, `agent:main:${sessionKey}`];
196
+ let entry: { sessionId?: string; sessionFile?: string; [key: string]: unknown } | undefined;
197
+ for (const key of candidates) {
198
+ if (store[key]) {
199
+ entry = store[key];
200
+ break;
201
+ }
202
+ }
203
+
204
+ if (!entry?.sessionId) {
205
+ log?.info?.(`[adp-openclaw] Session entry not found for transcript trimming: ${sessionKey}`);
206
+ return;
207
+ }
208
+
209
+ // Resolve transcript file path
210
+ let transcriptPath: string;
211
+ if (entry.sessionFile) {
212
+ transcriptPath = entry.sessionFile;
213
+ } else {
214
+ // Fallback: same directory as sessions.json, filename = <sessionId>.jsonl
215
+ transcriptPath = path.join(path.dirname(storePath), `${entry.sessionId}.jsonl`);
216
+ }
217
+
218
+ if (!fs.existsSync(transcriptPath)) {
219
+ log?.info?.(`[adp-openclaw] Transcript file not found: ${transcriptPath}`);
220
+ return;
221
+ }
222
+
223
+ // Read and parse JSONL lines
224
+ const content = fs.readFileSync(transcriptPath, "utf-8");
225
+ const lines = content.split("\n").filter((l) => l.trim());
226
+
227
+ if (lines.length === 0) return;
228
+
229
+ // Parse each line into JSON entries
230
+ type TranscriptEntry = {
231
+ type?: string;
232
+ id?: string;
233
+ parentId?: string | null;
234
+ message?: { role?: string; content?: unknown };
235
+ [key: string]: unknown;
236
+ };
237
+
238
+ const entries: TranscriptEntry[] = [];
239
+ for (const line of lines) {
240
+ try {
241
+ entries.push(JSON.parse(line));
242
+ } catch {
243
+ entries.push({ _raw: line } as unknown as TranscriptEntry);
244
+ }
245
+ }
246
+
247
+ // Find the last user message entry (searching from the end)
248
+ let lastUserMsgIdx = -1;
249
+ for (let i = entries.length - 1; i >= 0; i--) {
250
+ const e = entries[i];
251
+ if (e.type === "message" && e.message?.role === "user") {
252
+ lastUserMsgIdx = i;
253
+ break;
254
+ }
255
+ }
256
+
257
+ if (lastUserMsgIdx < 0) {
258
+ log?.info?.(`[adp-openclaw] No user message found in transcript to trim`);
259
+ return;
260
+ }
261
+
262
+ // Check if the last user message is at or near the end (within last few entries)
263
+ // This ensures we only trim the most recent aborted conversation, not old ones
264
+ if (lastUserMsgIdx < entries.length - 5) {
265
+ log?.info?.(`[adp-openclaw] Last user message is not recent enough to trim (idx=${lastUserMsgIdx}, total=${entries.length})`);
266
+ return;
267
+ }
268
+
269
+ // Truncate: keep everything before the last user message
270
+ const trimmedLines = lines.slice(0, lastUserMsgIdx);
271
+ const trimmedContent = trimmedLines.join("\n") + "\n";
272
+
273
+ fs.writeFileSync(transcriptPath, trimmedContent, "utf-8");
274
+
275
+ const removedCount = lines.length - lastUserMsgIdx;
276
+ log?.info?.(`[adp-openclaw] Trimmed ${removedCount} aborted entries from transcript (kept ${lastUserMsgIdx} entries)`);
277
+ } catch (err) {
278
+ log?.error?.(`[adp-openclaw] Failed to trim aborted messages from transcript: ${err}`);
279
+ }
280
+ }
281
+
119
282
  export async function monitorAdpOpenclaw(params: MonitorParams): Promise<void> {
120
283
  const { wsUrl, clientToken, signKey, abortSignal, log, cfg } = params;
121
284
  const runtime = getAdpOpenclawRuntime();
@@ -255,6 +418,7 @@ async function connectAndHandle(params: ConnectParams): Promise<void> {
255
418
  log?.info(`[adp-openclaw] Received: ${inMsg.from}: ${inMsg.text} (conv=${inMsg.conversationId}, rec=${inMsg.recordId || 'none'}, user=${JSON.stringify(inMsg.user || {})})`);
256
419
 
257
420
  // Process the message with full user identity
421
+ const convIdForCleanup = inMsg.conversationId || `fallback-${Date.now()}`;
258
422
  try {
259
423
  // Build user identity string for From field (like Feishu: "feishu:user_id")
260
424
  const userIdentifier = inMsg.user?.userId || inMsg.from;
@@ -582,6 +746,25 @@ async function connectAndHandle(params: ConnectParams): Promise<void> {
582
746
  const cancelText = lastPartialText ? `${lastPartialText}\n\n[已停止生成]` : "[已停止生成]";
583
747
  log?.info(`[adp-openclaw] Generation cancelled, sending outbound_end with partial text`);
584
748
  sendOutboundEnd(cancelText);
749
+
750
+ // Mark the session as aborted so the SDK injects an "abort hint"
751
+ // on the next message, preventing the AI from resuming the cancelled task
752
+ await markSessionAborted({
753
+ sessionKey: route.sessionKey,
754
+ runtime,
755
+ cfg,
756
+ log,
757
+ });
758
+
759
+ // Remove the aborted user message and partial assistant reply from
760
+ // the JSONL transcript. Without this, the AI sees the old cancelled
761
+ // request in its context and may resume it instead of answering the new one.
762
+ await trimAbortedMessagesFromTranscript({
763
+ sessionKey: route.sessionKey,
764
+ runtime,
765
+ cfg,
766
+ log,
767
+ });
585
768
  }
586
769
 
587
770
  // IMPORTANT: After dispatchReplyWithBufferedBlockDispatcher completes,
@@ -595,7 +778,7 @@ async function connectAndHandle(params: ConnectParams): Promise<void> {
595
778
  }
596
779
  } catch (err) {
597
780
  // Clean up on error
598
- activeGenerations.delete(convId);
781
+ activeGenerations.delete(convIdForCleanup);
599
782
  log?.error(`[adp-openclaw] Failed to process message: ${err}`);
600
783
  }
601
784
  break;