@alfe.ai/openclaw-chat 0.5.1 → 0.6.0
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/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/plugin2.cjs +543 -325
- package/dist/plugin2.js +544 -326
- package/package.json +1 -1
package/dist/plugin2.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, open, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { ChatServiceClient, resolveAlfeChat } from "@alfe.ai/chat";
|
|
@@ -369,6 +369,26 @@ async function appendActivity(sessionId, entries) {
|
|
|
369
369
|
writeLocks.set(sessionId, next);
|
|
370
370
|
await next;
|
|
371
371
|
}
|
|
372
|
+
const MAX_ROUTES_PER_SESSION = 8;
|
|
373
|
+
/**
|
|
374
|
+
* Record the OpenClaw route a turn dispatched through (idempotent upsert
|
|
375
|
+
* by sessionKey). Called once per turn from the dispatch success path.
|
|
376
|
+
*/
|
|
377
|
+
async function recordRoute(sessionId, route) {
|
|
378
|
+
const next = (writeLocks.get(sessionId) ?? Promise.resolve()).then(async () => {
|
|
379
|
+
const session = await getSession(sessionId);
|
|
380
|
+
if (!session) return;
|
|
381
|
+
const routes = session.routes ?? [];
|
|
382
|
+
if (routes.some((r) => r.sessionKey === route.sessionKey)) return;
|
|
383
|
+
if (routes.length >= MAX_ROUTES_PER_SESSION) return;
|
|
384
|
+
session.routes = [...routes, route];
|
|
385
|
+
await saveSession(session);
|
|
386
|
+
}).finally(() => {
|
|
387
|
+
if (writeLocks.get(sessionId) === next) writeLocks.delete(sessionId);
|
|
388
|
+
});
|
|
389
|
+
writeLocks.set(sessionId, next);
|
|
390
|
+
await next;
|
|
391
|
+
}
|
|
372
392
|
async function listSessions(filters, limit = 50) {
|
|
373
393
|
await ensureDir();
|
|
374
394
|
let files;
|
|
@@ -406,244 +426,6 @@ async function listSessions(filters, limit = 50) {
|
|
|
406
426
|
return summaries.slice(0, limit);
|
|
407
427
|
}
|
|
408
428
|
//#endregion
|
|
409
|
-
//#region src/a2a-tools.ts
|
|
410
|
-
let conversationEndRequested = false;
|
|
411
|
-
let a2aTurnArmed = false;
|
|
412
|
-
/**
|
|
413
|
-
* Clear the end signal AND arm the gate. Call immediately before an A2A
|
|
414
|
-
* dispatch. Arming is what permits `end_conversation` to set the flag for the
|
|
415
|
-
* duration of this turn.
|
|
416
|
-
*/
|
|
417
|
-
function resetA2AEndSignal() {
|
|
418
|
-
conversationEndRequested = false;
|
|
419
|
-
a2aTurnArmed = true;
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Clear the end signal AND disarm the gate. Call after an A2A dispatch (in a
|
|
423
|
-
* `finally`, so it also runs on error). Disarming closes the gate so a later
|
|
424
|
-
* NON-A2A turn invoking `end_conversation` can't set a flag the next A2A turn
|
|
425
|
-
* would read.
|
|
426
|
-
*/
|
|
427
|
-
function disarmA2AEndSignal() {
|
|
428
|
-
conversationEndRequested = false;
|
|
429
|
-
a2aTurnArmed = false;
|
|
430
|
-
}
|
|
431
|
-
/** True if `end_conversation` was invoked since the last reset. */
|
|
432
|
-
function isA2AEndSignalled() {
|
|
433
|
-
return conversationEndRequested;
|
|
434
|
-
}
|
|
435
|
-
function ok(result) {
|
|
436
|
-
return { content: [{
|
|
437
|
-
type: "text",
|
|
438
|
-
text: JSON.stringify(result)
|
|
439
|
-
}] };
|
|
440
|
-
}
|
|
441
|
-
function errResult(message) {
|
|
442
|
-
return { content: [{
|
|
443
|
-
type: "text",
|
|
444
|
-
text: JSON.stringify({ error: message })
|
|
445
|
-
}] };
|
|
446
|
-
}
|
|
447
|
-
function defineTool(def) {
|
|
448
|
-
return {
|
|
449
|
-
name: def.name,
|
|
450
|
-
description: def.description,
|
|
451
|
-
label: def.name,
|
|
452
|
-
parameters: def.parameters,
|
|
453
|
-
execute: async (_toolCallId, params) => {
|
|
454
|
-
try {
|
|
455
|
-
return ok(await def.handler(params));
|
|
456
|
-
} catch (e) {
|
|
457
|
-
return errResult(e.message);
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
};
|
|
461
|
-
}
|
|
462
|
-
function buildA2ATools(getChatClient, log) {
|
|
463
|
-
const requireClient = () => {
|
|
464
|
-
const client = getChatClient();
|
|
465
|
-
if (!client) throw new Error("chat service not connected yet — try again in a moment");
|
|
466
|
-
return client;
|
|
467
|
-
};
|
|
468
|
-
return [
|
|
469
|
-
defineTool({
|
|
470
|
-
name: "list_agents",
|
|
471
|
-
description: "List other agents in your organization that you can communicate with.",
|
|
472
|
-
parameters: {
|
|
473
|
-
type: "object",
|
|
474
|
-
properties: {},
|
|
475
|
-
required: []
|
|
476
|
-
},
|
|
477
|
-
handler: async () => {
|
|
478
|
-
log.info("list_agents tool called");
|
|
479
|
-
return await requireClient().sendRequest("a2a.list-agents", {});
|
|
480
|
-
}
|
|
481
|
-
}),
|
|
482
|
-
defineTool({
|
|
483
|
-
name: "message_agent",
|
|
484
|
-
description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
|
|
485
|
-
parameters: {
|
|
486
|
-
type: "object",
|
|
487
|
-
properties: {
|
|
488
|
-
agent_id: {
|
|
489
|
-
type: "string",
|
|
490
|
-
description: "Target agent ID (use list_agents to find available agents)"
|
|
491
|
-
},
|
|
492
|
-
message: {
|
|
493
|
-
type: "string",
|
|
494
|
-
description: "Message to send to the agent"
|
|
495
|
-
},
|
|
496
|
-
thread_id: {
|
|
497
|
-
type: "string",
|
|
498
|
-
description: "Continue an existing conversation thread (omit to start a new one)"
|
|
499
|
-
},
|
|
500
|
-
max_depth: {
|
|
501
|
-
type: "number",
|
|
502
|
-
description: "Maximum number of back-and-forth exchanges (default: 10)"
|
|
503
|
-
}
|
|
504
|
-
},
|
|
505
|
-
required: ["agent_id", "message"]
|
|
506
|
-
},
|
|
507
|
-
handler: async (params) => {
|
|
508
|
-
const agentId = params.agent_id;
|
|
509
|
-
const message = params.message;
|
|
510
|
-
const threadId = params.thread_id;
|
|
511
|
-
const maxDepth = params.max_depth ?? 10;
|
|
512
|
-
if (!agentId || !message) throw new Error("agent_id and message are required");
|
|
513
|
-
log.info(`message_agent tool: sending to ${agentId}`);
|
|
514
|
-
return await requireClient().sendRequest("a2a.send", {
|
|
515
|
-
targetAgentId: agentId,
|
|
516
|
-
message,
|
|
517
|
-
threadId,
|
|
518
|
-
maxDepth
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
}),
|
|
522
|
-
defineTool({
|
|
523
|
-
name: "end_conversation",
|
|
524
|
-
description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
|
|
525
|
-
parameters: {
|
|
526
|
-
type: "object",
|
|
527
|
-
properties: { summary: {
|
|
528
|
-
type: "string",
|
|
529
|
-
description: "Brief summary of what was discussed or resolved"
|
|
530
|
-
} },
|
|
531
|
-
required: []
|
|
532
|
-
},
|
|
533
|
-
handler: (params) => {
|
|
534
|
-
const summary = params.summary;
|
|
535
|
-
log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
|
|
536
|
-
if (a2aTurnArmed) conversationEndRequested = true;
|
|
537
|
-
return Promise.resolve({
|
|
538
|
-
status: "conversation_ended",
|
|
539
|
-
summary
|
|
540
|
-
});
|
|
541
|
-
}
|
|
542
|
-
})
|
|
543
|
-
];
|
|
544
|
-
}
|
|
545
|
-
//#endregion
|
|
546
|
-
//#region src/attachment-url.ts
|
|
547
|
-
/**
|
|
548
|
-
* Attachment URL validation for the openclaw-chat plugin.
|
|
549
|
-
*
|
|
550
|
-
* This package runs inside user-installed OpenClaw agents and has no
|
|
551
|
-
* dependency on the internal @alfe/api-core SSRF validator, so the
|
|
552
|
-
* plumbing is inlined here. The rules are deliberately conservative:
|
|
553
|
-
*
|
|
554
|
-
* - `https:` only.
|
|
555
|
-
* - Userinfo (`https://u:p@host/...`) is rejected.
|
|
556
|
-
* - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
|
|
557
|
-
* DNS names for known provider CDNs are acceptable.
|
|
558
|
-
* - The host must match a curated allowlist of Alfe-issued / channel-
|
|
559
|
-
* provider CDNs. Anything else is dropped before we fetch it.
|
|
560
|
-
*
|
|
561
|
-
* Operators running an agent in a private network that needs to dereference
|
|
562
|
-
* additional hosts can extend the list via the
|
|
563
|
-
* `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
|
|
564
|
-
* with `.` match suffixes).
|
|
565
|
-
*/
|
|
566
|
-
const DEFAULT_EXACT_HOSTS = [
|
|
567
|
-
"s3.amazonaws.com",
|
|
568
|
-
"api.twilio.com",
|
|
569
|
-
"graph.microsoft.com",
|
|
570
|
-
"mmg.whatsapp.net"
|
|
571
|
-
];
|
|
572
|
-
const DEFAULT_SUFFIX_HOSTS = [
|
|
573
|
-
".s3.amazonaws.com",
|
|
574
|
-
".amazonaws.com",
|
|
575
|
-
".twiliocdn.com",
|
|
576
|
-
".cdn.discordapp.com",
|
|
577
|
-
".discordapp.net",
|
|
578
|
-
".telegram.org",
|
|
579
|
-
".alfe.ai"
|
|
580
|
-
];
|
|
581
|
-
function parseExtraHosts(raw) {
|
|
582
|
-
const exact = /* @__PURE__ */ new Set();
|
|
583
|
-
const suffix = [];
|
|
584
|
-
if (!raw) return {
|
|
585
|
-
exact,
|
|
586
|
-
suffix
|
|
587
|
-
};
|
|
588
|
-
for (const part of raw.split(",")) {
|
|
589
|
-
const trimmed = part.trim().toLowerCase();
|
|
590
|
-
if (!trimmed) continue;
|
|
591
|
-
if (trimmed.startsWith(".")) suffix.push(trimmed);
|
|
592
|
-
else exact.add(trimmed);
|
|
593
|
-
}
|
|
594
|
-
return {
|
|
595
|
-
exact,
|
|
596
|
-
suffix
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
function isIpLiteral(host) {
|
|
600
|
-
if (host.startsWith("[") && host.endsWith("]")) return true;
|
|
601
|
-
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
602
|
-
}
|
|
603
|
-
function validateAttachmentUrl(input, opts = {}) {
|
|
604
|
-
if (!input || input.trim() === "") return {
|
|
605
|
-
ok: false,
|
|
606
|
-
reason: "empty"
|
|
607
|
-
};
|
|
608
|
-
let url;
|
|
609
|
-
try {
|
|
610
|
-
url = new URL(input);
|
|
611
|
-
} catch {
|
|
612
|
-
return {
|
|
613
|
-
ok: false,
|
|
614
|
-
reason: "invalid_url"
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
if (url.protocol !== "https:") return {
|
|
618
|
-
ok: false,
|
|
619
|
-
reason: "not_https"
|
|
620
|
-
};
|
|
621
|
-
if (url.username !== "" || url.password !== "") return {
|
|
622
|
-
ok: false,
|
|
623
|
-
reason: "userinfo_not_allowed"
|
|
624
|
-
};
|
|
625
|
-
const host = url.hostname.toLowerCase();
|
|
626
|
-
if (!host) return {
|
|
627
|
-
ok: false,
|
|
628
|
-
reason: "empty_host"
|
|
629
|
-
};
|
|
630
|
-
if (isIpLiteral(host)) return {
|
|
631
|
-
ok: false,
|
|
632
|
-
reason: "ip_literal"
|
|
633
|
-
};
|
|
634
|
-
if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
|
|
635
|
-
ok: false,
|
|
636
|
-
reason: "blocked_host"
|
|
637
|
-
};
|
|
638
|
-
const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
|
|
639
|
-
if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
|
|
640
|
-
if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
|
|
641
|
-
return {
|
|
642
|
-
ok: false,
|
|
643
|
-
reason: "host_not_in_allowlist"
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
//#endregion
|
|
647
429
|
//#region src/activity-serialize.ts
|
|
648
430
|
/**
|
|
649
431
|
* Console-grade tool-activity serialization (Slice 3).
|
|
@@ -873,99 +655,514 @@ function extractResultText(result) {
|
|
|
873
655
|
return parts.join("\n");
|
|
874
656
|
}
|
|
875
657
|
//#endregion
|
|
876
|
-
//#region src/think-tags.ts
|
|
658
|
+
//#region src/think-tags.ts
|
|
659
|
+
/**
|
|
660
|
+
* Inline `<think>` span handling for assistant text.
|
|
661
|
+
*
|
|
662
|
+
* Reasoning models routed through the AI proxy (DeepSeek `deepseek-*`,
|
|
663
|
+
* Zhipu `glm-*`, …) emit their reasoning INLINE as `<think>…</think>` inside
|
|
664
|
+
* the normal content stream instead of a structured thinking channel. The
|
|
665
|
+
* openclaw-chat plugin (0.4.0+) diverts those spans into thinking activity
|
|
666
|
+
* frames at the source; this relay-side copy is the DEFENSIVE layer for
|
|
667
|
+
* agents still running older plugin versions — leaked reasoning is dropped
|
|
668
|
+
* here, never synthesized into activity (that's the plugin's job).
|
|
669
|
+
*
|
|
670
|
+
* KEEP IN SYNC (byte-identical): this module exists at BOTH
|
|
671
|
+
* `services/chat/src/lib/think-tags.ts` (relay, defensive layer)
|
|
672
|
+
* `packages/openclaw-chat/src/think-tags.ts` (plugin, at-source divert)
|
|
673
|
+
* The published plugin cannot import service internals, so the module is
|
|
674
|
+
* duplicated, same as the ChatActivity shape.
|
|
675
|
+
*
|
|
676
|
+
* Known accepted limitation: a literal `<think>` in legitimate prose or a
|
|
677
|
+
* code fence is treated as reasoning and stripped/diverted.
|
|
678
|
+
*/
|
|
679
|
+
const OPEN_TAG = "<think>";
|
|
680
|
+
const CLOSE_TAG = "</think>";
|
|
681
|
+
/**
|
|
682
|
+
* Split CUMULATIVE assistant text into the user-visible part and the
|
|
683
|
+
* reasoning part.
|
|
684
|
+
*
|
|
685
|
+
* Invariant (load-bearing): for successive cumulative snapshots that grow by
|
|
686
|
+
* appending, `visible` is monotonically append-only — `visible(n)` is always
|
|
687
|
+
* a prefix of `visible(n + 1)`. The delta relays slice by a `lastSentLength`
|
|
688
|
+
* offset into this string; if `visible` ever shrank or rewrote earlier
|
|
689
|
+
* characters, clients would receive corrupted deltas. This holds because the
|
|
690
|
+
* scan is deterministic left-to-right and a trailing PARTIAL tag (any strict
|
|
691
|
+
* prefix of the next expected tag, e.g. `"<thi"` or `"</th"`) is HELD BACK
|
|
692
|
+
* from both outputs until later text disambiguates it.
|
|
693
|
+
*/
|
|
694
|
+
function splitThinkTags(cumulative) {
|
|
695
|
+
let visible = "";
|
|
696
|
+
let thinking = "";
|
|
697
|
+
let inThink = false;
|
|
698
|
+
let i = 0;
|
|
699
|
+
const n = cumulative.length;
|
|
700
|
+
while (i < n) {
|
|
701
|
+
const tag = inThink ? CLOSE_TAG : OPEN_TAG;
|
|
702
|
+
const idx = cumulative.indexOf(tag, i);
|
|
703
|
+
if (idx !== -1) {
|
|
704
|
+
const chunk = cumulative.slice(i, idx);
|
|
705
|
+
if (inThink) thinking += chunk;
|
|
706
|
+
else visible += chunk;
|
|
707
|
+
inThink = !inThink;
|
|
708
|
+
i = idx + tag.length;
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
let rest = cumulative.slice(i);
|
|
712
|
+
const held = trailingPartialTagLength(rest, tag);
|
|
713
|
+
if (held > 0) rest = rest.slice(0, rest.length - held);
|
|
714
|
+
if (inThink) thinking += rest;
|
|
715
|
+
else visible += rest;
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
return {
|
|
719
|
+
visible,
|
|
720
|
+
thinking
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/** Length of the longest strict prefix of `tag` that is a suffix of `text`. */
|
|
724
|
+
function trailingPartialTagLength(text, tag) {
|
|
725
|
+
const max = Math.min(text.length, tag.length - 1);
|
|
726
|
+
for (let len = max; len > 0; len--) if (text.endsWith(tag.slice(0, len))) return len;
|
|
727
|
+
return 0;
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Strip `<think>…</think>` spans (and an unclosed trailing `<think>…`) from
|
|
731
|
+
* FINAL text, then tidy the whitespace the spans leave behind.
|
|
732
|
+
*
|
|
733
|
+
* Idempotent — `stripThinkSpans(stripThinkSpans(x)) === stripThinkSpans(x)`.
|
|
734
|
+
* New plugin + new relay both strip, so the second pass must be a no-op; the
|
|
735
|
+
* fixpoint loop also covers pathological inputs where removing one span
|
|
736
|
+
* makes the surrounding fragments form a new one.
|
|
737
|
+
*
|
|
738
|
+
* NOT for sliced/streaming paths — the trim + blank-line collapse breaks
|
|
739
|
+
* prefix alignment with `lastSentLength`; those paths use
|
|
740
|
+
* `splitThinkTags(...).visible` instead.
|
|
741
|
+
*/
|
|
742
|
+
function stripThinkSpans(text) {
|
|
743
|
+
let out = text;
|
|
744
|
+
let prev;
|
|
745
|
+
do {
|
|
746
|
+
prev = out;
|
|
747
|
+
out = out.replace(/<think>[\s\S]*?<\/think>/g, "");
|
|
748
|
+
} while (out !== prev);
|
|
749
|
+
out = out.replace(/<think>[\s\S]*$/, "");
|
|
750
|
+
return out.replace(/\n{3,}/g, "\n\n").trim();
|
|
751
|
+
}
|
|
752
|
+
//#endregion
|
|
753
|
+
//#region src/transcript-activity.ts
|
|
754
|
+
/**
|
|
755
|
+
* Transcript-backed activity history — read tool calls + thinking for a
|
|
756
|
+
* conversation from OPENCLAW'S OWN session transcripts.
|
|
757
|
+
*
|
|
758
|
+
* Why: every chat turn dispatches through OpenClaw's standard pipeline,
|
|
759
|
+
* which records a full per-session JSONL transcript (assistant `thinking`
|
|
760
|
+
* and `toolCall` content parts, separate `toolResult` messages, exact
|
|
761
|
+
* timestamps) under `~/.openclaw/agents/{agentId}/sessions/`. That store is
|
|
762
|
+
* the console's history source and has FULL fidelity — including turns
|
|
763
|
+
* that ran before this plugin started persisting its own activity records.
|
|
764
|
+
* `sessions.get` therefore prefers the transcript; the plugin's own
|
|
765
|
+
* `SessionData.activity` records remain the FALLBACK for transcripts that
|
|
766
|
+
* are missing (deleted / rotated / compacted).
|
|
767
|
+
*
|
|
768
|
+
* Layout (verified against openclaw 2026.5.x, matches the plugin-sdk's
|
|
769
|
+
* `resolveSessionTranscriptPathInDir`):
|
|
770
|
+
* {storeDir}/sessions.json — index: sessionKey → {sessionId}
|
|
771
|
+
* {storeDir}/{sessionId}.jsonl — the transcript
|
|
772
|
+
* where `storePath` (as returned by dispatchInboundDirectDmWithRuntime)
|
|
773
|
+
* points at sessions.json.
|
|
774
|
+
*
|
|
775
|
+
* Conversation → transcript mapping: routes recorded at dispatch time
|
|
776
|
+
* (exact sessionKey + storePath) cover every conversation shape going
|
|
777
|
+
* forward. The additional `:conv:{conversationId}` index scan is the
|
|
778
|
+
* RETROACTIVE path for conversations that predate route recording — it
|
|
779
|
+
* only applies to DIRECT web-chat conversations (their peer id embeds the
|
|
780
|
+
* `:conv:` segment); group and single-threaded SMS/WhatsApp keys carry no
|
|
781
|
+
* such segment and rely entirely on recorded routes. All matched sessions
|
|
782
|
+
* (a direct conversation can span one per sender) merge by timestamp.
|
|
783
|
+
*/
|
|
784
|
+
const MAX_ENTRIES = 500;
|
|
785
|
+
const MAX_THINKING_CHARS = 16e3;
|
|
786
|
+
const MAX_TRANSCRIPT_BYTES = 4 * 1024 * 1024;
|
|
787
|
+
/** Default OpenClaw store for the daemon's single agent ("main"). */
|
|
788
|
+
function defaultStorePath() {
|
|
789
|
+
return join(homedir(), ".openclaw", "agents", "main", "sessions", "sessions.json");
|
|
790
|
+
}
|
|
791
|
+
function parseTs(iso) {
|
|
792
|
+
const ts = iso ? Date.parse(iso) : NaN;
|
|
793
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Read at most the trailing MAX_TRANSCRIPT_BYTES of a transcript. When the
|
|
797
|
+
* cap truncates, the first (partial) line is dropped so parsing starts on a
|
|
798
|
+
* record boundary. Returns null when the file is unreadable.
|
|
799
|
+
*/
|
|
800
|
+
async function readTranscriptTail(path) {
|
|
801
|
+
try {
|
|
802
|
+
const handle = await open(path, "r");
|
|
803
|
+
try {
|
|
804
|
+
const { size } = await handle.stat();
|
|
805
|
+
if (size <= MAX_TRANSCRIPT_BYTES) return await handle.readFile({ encoding: "utf-8" });
|
|
806
|
+
const buf = Buffer.alloc(MAX_TRANSCRIPT_BYTES);
|
|
807
|
+
await handle.read(buf, 0, MAX_TRANSCRIPT_BYTES, size - MAX_TRANSCRIPT_BYTES);
|
|
808
|
+
const text = buf.toString("utf-8");
|
|
809
|
+
const firstNewline = text.indexOf("\n");
|
|
810
|
+
return firstNewline === -1 ? "" : text.slice(firstNewline + 1);
|
|
811
|
+
} finally {
|
|
812
|
+
await handle.close();
|
|
813
|
+
}
|
|
814
|
+
} catch {
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Find the transcript files for a conversation: exact route matches first,
|
|
820
|
+
* then an index scan for `:conv:{conversationId}` keys (retroactive path).
|
|
821
|
+
* Returns absolute jsonl paths, deduped.
|
|
822
|
+
*/
|
|
823
|
+
async function resolveTranscriptPaths(conversationId, routes) {
|
|
824
|
+
const storePaths = new Set((routes ?? []).map((r) => r.storePath));
|
|
825
|
+
if (storePaths.size === 0) storePaths.add(defaultStorePath());
|
|
826
|
+
const routeKeys = new Set((routes ?? []).map((r) => r.sessionKey));
|
|
827
|
+
const convSegment = `:conv:${conversationId}`;
|
|
828
|
+
const files = /* @__PURE__ */ new Set();
|
|
829
|
+
for (const storePath of storePaths) {
|
|
830
|
+
let index;
|
|
831
|
+
try {
|
|
832
|
+
index = JSON.parse(await readFile(storePath, "utf-8"));
|
|
833
|
+
} catch {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const dir = dirname(storePath);
|
|
837
|
+
for (const [key, entry] of Object.entries(index)) {
|
|
838
|
+
if (!entry.sessionId) continue;
|
|
839
|
+
if (!(routeKeys.has(key) || key.endsWith(convSegment) || key.includes(`${convSegment}:`))) continue;
|
|
840
|
+
if (!/^[A-Za-z0-9._-]+$/.test(entry.sessionId)) continue;
|
|
841
|
+
files.add(join(dir, `${entry.sessionId}.jsonl`));
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return [...files];
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Parse one transcript file into terminal activity records.
|
|
848
|
+
*
|
|
849
|
+
* - assistant `thinking` parts (and `<think>`-tagged text for inline
|
|
850
|
+
* reasoning models) aggregate into ONE thinking record per assistant
|
|
851
|
+
* message;
|
|
852
|
+
* - `toolCall` parts open a tool record; the matching `toolResult` message
|
|
853
|
+
* completes it (status/result). Tools with no result stay `interrupted`.
|
|
854
|
+
*/
|
|
855
|
+
async function parseTranscriptActivity(path) {
|
|
856
|
+
const raw = await readTranscriptTail(path);
|
|
857
|
+
if (raw === null) return [];
|
|
858
|
+
const records = [];
|
|
859
|
+
const openTools = /* @__PURE__ */ new Map();
|
|
860
|
+
for (const line of raw.split("\n")) {
|
|
861
|
+
if (!line.trim()) continue;
|
|
862
|
+
let entry;
|
|
863
|
+
try {
|
|
864
|
+
entry = JSON.parse(line);
|
|
865
|
+
} catch {
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
if (entry.type !== "message" || !entry.message) continue;
|
|
869
|
+
const ts = parseTs(entry.timestamp);
|
|
870
|
+
const msg = entry.message;
|
|
871
|
+
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
|
872
|
+
let thinkingText = "";
|
|
873
|
+
const tools = [];
|
|
874
|
+
for (const part of msg.content) if (part.type === "thinking" && typeof part.thinking === "string") thinkingText += part.thinking;
|
|
875
|
+
else if (part.type === "text" && typeof part.text === "string") thinkingText += splitThinkTags(part.text).thinking;
|
|
876
|
+
else if (part.type === "toolCall" && typeof part.id === "string" && typeof part.name === "string") {
|
|
877
|
+
const args = serializeArgs(part.arguments);
|
|
878
|
+
const tool = {
|
|
879
|
+
kind: "tool",
|
|
880
|
+
toolCallId: part.id,
|
|
881
|
+
name: part.name,
|
|
882
|
+
status: "interrupted",
|
|
883
|
+
...args ? { argsText: args.text } : {},
|
|
884
|
+
...args?.truncated ? { truncated: { args: true } } : {},
|
|
885
|
+
ts
|
|
886
|
+
};
|
|
887
|
+
openTools.set(part.id, tool);
|
|
888
|
+
tools.push(tool);
|
|
889
|
+
}
|
|
890
|
+
if (thinkingText.trim()) records.push({
|
|
891
|
+
kind: "thinking",
|
|
892
|
+
text: thinkingText.length > MAX_THINKING_CHARS ? thinkingText.slice(0, MAX_THINKING_CHARS) : thinkingText,
|
|
893
|
+
ts
|
|
894
|
+
});
|
|
895
|
+
records.push(...tools);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (msg.role === "toolResult" && typeof msg.toolCallId === "string") {
|
|
899
|
+
const tool = openTools.get(msg.toolCallId);
|
|
900
|
+
if (!tool) continue;
|
|
901
|
+
tool.status = msg.isError === true ? "failed" : "done";
|
|
902
|
+
if (msg.isError === true) tool.isError = true;
|
|
903
|
+
const result = serializeResult({ content: msg.content });
|
|
904
|
+
if (result) {
|
|
905
|
+
tool.resultText = result.text;
|
|
906
|
+
if (result.truncated) tool.truncated = {
|
|
907
|
+
...tool.truncated ?? {},
|
|
908
|
+
result: true
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
openTools.delete(msg.toolCallId);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
return records;
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Full conversation activity from OpenClaw's transcripts: resolve, parse,
|
|
918
|
+
* merge (a group conversation can span several per-sender sessions), sort
|
|
919
|
+
* by timestamp, keep the most recent MAX_ENTRIES. Empty array when no
|
|
920
|
+
* transcript could be found — the caller falls back to the plugin's own
|
|
921
|
+
* persisted activity records.
|
|
922
|
+
*/
|
|
923
|
+
async function collectTranscriptActivity(conversationId, routes) {
|
|
924
|
+
const paths = await resolveTranscriptPaths(conversationId, routes);
|
|
925
|
+
if (paths.length === 0) return [];
|
|
926
|
+
const merged = (await Promise.all(paths.map((p) => parseTranscriptActivity(p)))).flat().sort((a, b) => a.ts - b.ts);
|
|
927
|
+
return merged.length > MAX_ENTRIES ? merged.slice(merged.length - MAX_ENTRIES) : merged;
|
|
928
|
+
}
|
|
929
|
+
//#endregion
|
|
930
|
+
//#region src/a2a-tools.ts
|
|
931
|
+
let conversationEndRequested = false;
|
|
932
|
+
let a2aTurnArmed = false;
|
|
933
|
+
/**
|
|
934
|
+
* Clear the end signal AND arm the gate. Call immediately before an A2A
|
|
935
|
+
* dispatch. Arming is what permits `end_conversation` to set the flag for the
|
|
936
|
+
* duration of this turn.
|
|
937
|
+
*/
|
|
938
|
+
function resetA2AEndSignal() {
|
|
939
|
+
conversationEndRequested = false;
|
|
940
|
+
a2aTurnArmed = true;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Clear the end signal AND disarm the gate. Call after an A2A dispatch (in a
|
|
944
|
+
* `finally`, so it also runs on error). Disarming closes the gate so a later
|
|
945
|
+
* NON-A2A turn invoking `end_conversation` can't set a flag the next A2A turn
|
|
946
|
+
* would read.
|
|
947
|
+
*/
|
|
948
|
+
function disarmA2AEndSignal() {
|
|
949
|
+
conversationEndRequested = false;
|
|
950
|
+
a2aTurnArmed = false;
|
|
951
|
+
}
|
|
952
|
+
/** True if `end_conversation` was invoked since the last reset. */
|
|
953
|
+
function isA2AEndSignalled() {
|
|
954
|
+
return conversationEndRequested;
|
|
955
|
+
}
|
|
956
|
+
function ok(result) {
|
|
957
|
+
return { content: [{
|
|
958
|
+
type: "text",
|
|
959
|
+
text: JSON.stringify(result)
|
|
960
|
+
}] };
|
|
961
|
+
}
|
|
962
|
+
function errResult(message) {
|
|
963
|
+
return { content: [{
|
|
964
|
+
type: "text",
|
|
965
|
+
text: JSON.stringify({ error: message })
|
|
966
|
+
}] };
|
|
967
|
+
}
|
|
968
|
+
function defineTool(def) {
|
|
969
|
+
return {
|
|
970
|
+
name: def.name,
|
|
971
|
+
description: def.description,
|
|
972
|
+
label: def.name,
|
|
973
|
+
parameters: def.parameters,
|
|
974
|
+
execute: async (_toolCallId, params) => {
|
|
975
|
+
try {
|
|
976
|
+
return ok(await def.handler(params));
|
|
977
|
+
} catch (e) {
|
|
978
|
+
return errResult(e.message);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function buildA2ATools(getChatClient, log) {
|
|
984
|
+
const requireClient = () => {
|
|
985
|
+
const client = getChatClient();
|
|
986
|
+
if (!client) throw new Error("chat service not connected yet — try again in a moment");
|
|
987
|
+
return client;
|
|
988
|
+
};
|
|
989
|
+
return [
|
|
990
|
+
defineTool({
|
|
991
|
+
name: "list_agents",
|
|
992
|
+
description: "List other agents in your organization that you can communicate with.",
|
|
993
|
+
parameters: {
|
|
994
|
+
type: "object",
|
|
995
|
+
properties: {},
|
|
996
|
+
required: []
|
|
997
|
+
},
|
|
998
|
+
handler: async () => {
|
|
999
|
+
log.info("list_agents tool called");
|
|
1000
|
+
return await requireClient().sendRequest("a2a.list-agents", {});
|
|
1001
|
+
}
|
|
1002
|
+
}),
|
|
1003
|
+
defineTool({
|
|
1004
|
+
name: "message_agent",
|
|
1005
|
+
description: "Send a message to another agent. Starts a new conversation thread, or continues an existing one. The conversation will bounce back and forth automatically until one agent calls end_conversation() or says [RESOLVED].",
|
|
1006
|
+
parameters: {
|
|
1007
|
+
type: "object",
|
|
1008
|
+
properties: {
|
|
1009
|
+
agent_id: {
|
|
1010
|
+
type: "string",
|
|
1011
|
+
description: "Target agent ID (use list_agents to find available agents)"
|
|
1012
|
+
},
|
|
1013
|
+
message: {
|
|
1014
|
+
type: "string",
|
|
1015
|
+
description: "Message to send to the agent"
|
|
1016
|
+
},
|
|
1017
|
+
thread_id: {
|
|
1018
|
+
type: "string",
|
|
1019
|
+
description: "Continue an existing conversation thread (omit to start a new one)"
|
|
1020
|
+
},
|
|
1021
|
+
max_depth: {
|
|
1022
|
+
type: "number",
|
|
1023
|
+
description: "Maximum number of back-and-forth exchanges (default: 10)"
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
required: ["agent_id", "message"]
|
|
1027
|
+
},
|
|
1028
|
+
handler: async (params) => {
|
|
1029
|
+
const agentId = params.agent_id;
|
|
1030
|
+
const message = params.message;
|
|
1031
|
+
const threadId = params.thread_id;
|
|
1032
|
+
const maxDepth = params.max_depth ?? 10;
|
|
1033
|
+
if (!agentId || !message) throw new Error("agent_id and message are required");
|
|
1034
|
+
log.info(`message_agent tool: sending to ${agentId}`);
|
|
1035
|
+
return await requireClient().sendRequest("a2a.send", {
|
|
1036
|
+
targetAgentId: agentId,
|
|
1037
|
+
message,
|
|
1038
|
+
threadId,
|
|
1039
|
+
maxDepth
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}),
|
|
1043
|
+
defineTool({
|
|
1044
|
+
name: "end_conversation",
|
|
1045
|
+
description: "End the current agent-to-agent conversation. Call this when the discussion is resolved and no further exchanges are needed.",
|
|
1046
|
+
parameters: {
|
|
1047
|
+
type: "object",
|
|
1048
|
+
properties: { summary: {
|
|
1049
|
+
type: "string",
|
|
1050
|
+
description: "Brief summary of what was discussed or resolved"
|
|
1051
|
+
} },
|
|
1052
|
+
required: []
|
|
1053
|
+
},
|
|
1054
|
+
handler: (params) => {
|
|
1055
|
+
const summary = params.summary;
|
|
1056
|
+
log.info(`end_conversation tool called${summary ? `: ${summary}` : ""}`);
|
|
1057
|
+
if (a2aTurnArmed) conversationEndRequested = true;
|
|
1058
|
+
return Promise.resolve({
|
|
1059
|
+
status: "conversation_ended",
|
|
1060
|
+
summary
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
})
|
|
1064
|
+
];
|
|
1065
|
+
}
|
|
1066
|
+
//#endregion
|
|
1067
|
+
//#region src/attachment-url.ts
|
|
877
1068
|
/**
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
* Reasoning models routed through the AI proxy (DeepSeek `deepseek-*`,
|
|
881
|
-
* Zhipu `glm-*`, …) emit their reasoning INLINE as `<think>…</think>` inside
|
|
882
|
-
* the normal content stream instead of a structured thinking channel. The
|
|
883
|
-
* openclaw-chat plugin (0.4.0+) diverts those spans into thinking activity
|
|
884
|
-
* frames at the source; this relay-side copy is the DEFENSIVE layer for
|
|
885
|
-
* agents still running older plugin versions — leaked reasoning is dropped
|
|
886
|
-
* here, never synthesized into activity (that's the plugin's job).
|
|
1069
|
+
* Attachment URL validation for the openclaw-chat plugin.
|
|
887
1070
|
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
* The published plugin cannot import service internals, so the module is
|
|
892
|
-
* duplicated, same as the ChatActivity shape.
|
|
1071
|
+
* This package runs inside user-installed OpenClaw agents and has no
|
|
1072
|
+
* dependency on the internal @alfe/api-core SSRF validator, so the
|
|
1073
|
+
* plumbing is inlined here. The rules are deliberately conservative:
|
|
893
1074
|
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
* Split CUMULATIVE assistant text into the user-visible part and the
|
|
901
|
-
* reasoning part.
|
|
1075
|
+
* - `https:` only.
|
|
1076
|
+
* - Userinfo (`https://u:p@host/...`) is rejected.
|
|
1077
|
+
* - IP-literal hosts (v4 or bracketed v6) are rejected outright — only
|
|
1078
|
+
* DNS names for known provider CDNs are acceptable.
|
|
1079
|
+
* - The host must match a curated allowlist of Alfe-issued / channel-
|
|
1080
|
+
* provider CDNs. Anything else is dropped before we fetch it.
|
|
902
1081
|
*
|
|
903
|
-
*
|
|
904
|
-
*
|
|
905
|
-
*
|
|
906
|
-
*
|
|
907
|
-
* characters, clients would receive corrupted deltas. This holds because the
|
|
908
|
-
* scan is deterministic left-to-right and a trailing PARTIAL tag (any strict
|
|
909
|
-
* prefix of the next expected tag, e.g. `"<thi"` or `"</th"`) is HELD BACK
|
|
910
|
-
* from both outputs until later text disambiguates it.
|
|
1082
|
+
* Operators running an agent in a private network that needs to dereference
|
|
1083
|
+
* additional hosts can extend the list via the
|
|
1084
|
+
* `ALFE_ATTACHMENT_ALLOWED_HOSTS` env var (comma-separated, entries prefixed
|
|
1085
|
+
* with `.` match suffixes).
|
|
911
1086
|
*/
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1087
|
+
const DEFAULT_EXACT_HOSTS = [
|
|
1088
|
+
"s3.amazonaws.com",
|
|
1089
|
+
"api.twilio.com",
|
|
1090
|
+
"graph.microsoft.com",
|
|
1091
|
+
"mmg.whatsapp.net"
|
|
1092
|
+
];
|
|
1093
|
+
const DEFAULT_SUFFIX_HOSTS = [
|
|
1094
|
+
".s3.amazonaws.com",
|
|
1095
|
+
".amazonaws.com",
|
|
1096
|
+
".twiliocdn.com",
|
|
1097
|
+
".cdn.discordapp.com",
|
|
1098
|
+
".discordapp.net",
|
|
1099
|
+
".telegram.org",
|
|
1100
|
+
".alfe.ai"
|
|
1101
|
+
];
|
|
1102
|
+
function parseExtraHosts(raw) {
|
|
1103
|
+
const exact = /* @__PURE__ */ new Set();
|
|
1104
|
+
const suffix = [];
|
|
1105
|
+
if (!raw) return {
|
|
1106
|
+
exact,
|
|
1107
|
+
suffix
|
|
1108
|
+
};
|
|
1109
|
+
for (const part of raw.split(",")) {
|
|
1110
|
+
const trimmed = part.trim().toLowerCase();
|
|
1111
|
+
if (!trimmed) continue;
|
|
1112
|
+
if (trimmed.startsWith(".")) suffix.push(trimmed);
|
|
1113
|
+
else exact.add(trimmed);
|
|
935
1114
|
}
|
|
936
1115
|
return {
|
|
937
|
-
|
|
938
|
-
|
|
1116
|
+
exact,
|
|
1117
|
+
suffix
|
|
939
1118
|
};
|
|
940
1119
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
for (let len = max; len > 0; len--) if (text.endsWith(tag.slice(0, len))) return len;
|
|
945
|
-
return 0;
|
|
1120
|
+
function isIpLiteral(host) {
|
|
1121
|
+
if (host.startsWith("[") && host.endsWith("]")) return true;
|
|
1122
|
+
return /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
946
1123
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1124
|
+
function validateAttachmentUrl(input, opts = {}) {
|
|
1125
|
+
if (!input || input.trim() === "") return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
reason: "empty"
|
|
1128
|
+
};
|
|
1129
|
+
let url;
|
|
1130
|
+
try {
|
|
1131
|
+
url = new URL(input);
|
|
1132
|
+
} catch {
|
|
1133
|
+
return {
|
|
1134
|
+
ok: false,
|
|
1135
|
+
reason: "invalid_url"
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
if (url.protocol !== "https:") return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
reason: "not_https"
|
|
1141
|
+
};
|
|
1142
|
+
if (url.username !== "" || url.password !== "") return {
|
|
1143
|
+
ok: false,
|
|
1144
|
+
reason: "userinfo_not_allowed"
|
|
1145
|
+
};
|
|
1146
|
+
const host = url.hostname.toLowerCase();
|
|
1147
|
+
if (!host) return {
|
|
1148
|
+
ok: false,
|
|
1149
|
+
reason: "empty_host"
|
|
1150
|
+
};
|
|
1151
|
+
if (isIpLiteral(host)) return {
|
|
1152
|
+
ok: false,
|
|
1153
|
+
reason: "ip_literal"
|
|
1154
|
+
};
|
|
1155
|
+
if (host === "localhost" || host === "metadata" || host === "metadata.google.internal") return {
|
|
1156
|
+
ok: false,
|
|
1157
|
+
reason: "blocked_host"
|
|
1158
|
+
};
|
|
1159
|
+
const extra = parseExtraHosts(opts.extraHosts ?? process.env.ALFE_ATTACHMENT_ALLOWED_HOSTS);
|
|
1160
|
+
if (new Set([...DEFAULT_EXACT_HOSTS.map((h) => h.toLowerCase()), ...extra.exact]).has(host)) return { ok: true };
|
|
1161
|
+
if ([...DEFAULT_SUFFIX_HOSTS.map((h) => h.toLowerCase()), ...extra.suffix].some((rule) => host === rule.slice(1) || host.endsWith(rule))) return { ok: true };
|
|
1162
|
+
return {
|
|
1163
|
+
ok: false,
|
|
1164
|
+
reason: "host_not_in_allowlist"
|
|
1165
|
+
};
|
|
969
1166
|
}
|
|
970
1167
|
//#endregion
|
|
971
1168
|
//#region src/plugin.ts
|
|
@@ -1622,7 +1819,7 @@ async function handleAgentRequest(request, log) {
|
|
|
1622
1819
|
requestSessionKey: legacySessionKey,
|
|
1623
1820
|
routeSessionKey
|
|
1624
1821
|
};
|
|
1625
|
-
|
|
1822
|
+
const result = await dispatchInbound({
|
|
1626
1823
|
cfg,
|
|
1627
1824
|
runtime: { channel: runtime.channel },
|
|
1628
1825
|
channel: "alfe",
|
|
@@ -1680,7 +1877,14 @@ async function handleAgentRequest(request, log) {
|
|
|
1680
1877
|
onDispatchError: (err, info) => {
|
|
1681
1878
|
log.error(`Dispatch error (${info.kind}): ${err instanceof Error ? err.message : String(err)}`);
|
|
1682
1879
|
}
|
|
1683
|
-
})
|
|
1880
|
+
});
|
|
1881
|
+
runGate.sessionKey = result.route.sessionKey;
|
|
1882
|
+
recordRoute(sessionId, {
|
|
1883
|
+
sessionKey: result.route.sessionKey,
|
|
1884
|
+
storePath: result.storePath
|
|
1885
|
+
}).catch((err) => {
|
|
1886
|
+
log.warn(`recordRoute failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1887
|
+
});
|
|
1684
1888
|
if (isA2A) {
|
|
1685
1889
|
const a2aResolved = isA2AEndSignalled();
|
|
1686
1890
|
chatClient?.notify("a2a-complete", buildA2ACompletePayload({
|
|
@@ -1723,6 +1927,20 @@ async function handleSessionsList(request, log) {
|
|
|
1723
1927
|
chatClient?.sendResponse(request.id, false, { message: errMsg });
|
|
1724
1928
|
}
|
|
1725
1929
|
}
|
|
1930
|
+
/**
|
|
1931
|
+
* History activity for a conversation: OpenClaw's own transcript is the
|
|
1932
|
+
* PRIMARY source (full fidelity — tool results included — and retroactive
|
|
1933
|
+
* for conversations that predate the plugin's activity persistence); the
|
|
1934
|
+
* plugin's per-turn records are the fallback for missing/rotated
|
|
1935
|
+
* transcripts. Never throws — history must not fail a sessions.get.
|
|
1936
|
+
*/
|
|
1937
|
+
async function resolveHistoryActivity(session) {
|
|
1938
|
+
try {
|
|
1939
|
+
const fromTranscript = await collectTranscriptActivity(session.sessionId, session.routes);
|
|
1940
|
+
if (fromTranscript.length > 0) return fromTranscript;
|
|
1941
|
+
} catch {}
|
|
1942
|
+
return (session.activity ?? []).map((a) => ({ ...a }));
|
|
1943
|
+
}
|
|
1726
1944
|
async function handleSessionsGet(request, log) {
|
|
1727
1945
|
try {
|
|
1728
1946
|
const params = request.params;
|
|
@@ -1754,7 +1972,7 @@ async function handleSessionsGet(request, log) {
|
|
|
1754
1972
|
content: m.content,
|
|
1755
1973
|
timestamp: m.timestamp
|
|
1756
1974
|
})),
|
|
1757
|
-
activity: (session
|
|
1975
|
+
activity: await resolveHistoryActivity(session)
|
|
1758
1976
|
});
|
|
1759
1977
|
} catch (err) {
|
|
1760
1978
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -1894,7 +2112,7 @@ const plugin = {
|
|
|
1894
2112
|
content: m.content,
|
|
1895
2113
|
timestamp: m.timestamp
|
|
1896
2114
|
})),
|
|
1897
|
-
activity: (session
|
|
2115
|
+
activity: await resolveHistoryActivity(session)
|
|
1898
2116
|
};
|
|
1899
2117
|
});
|
|
1900
2118
|
gw.__alfeChatGatewayMethodsRegistered = true;
|