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