@hellolynk-ai/openclaw-channel 0.0.0-stage → 0.2.100
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/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/clients/core/hellolynk-agent-configurator.mjs +701 -0
- package/clients/core/hellolynk-client-account.mjs +180 -0
- package/clients/core/hellolynk-client-core.mjs +770 -0
- package/clients/core/hellolynk-client-profile.mjs +191 -0
- package/clients/core/hellolynk-client-update-entry.mjs +31 -0
- package/clients/core/hellolynk-client-update-runtime.mjs +600 -0
- package/clients/core/hellolynk-client-update.mjs +453 -0
- package/clients/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/clients/core/hellolynk-device-enrollment.mjs +435 -0
- package/clients/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/clients/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/clients/core/hellolynk-host-auth.mjs +347 -0
- package/clients/core/hellolynk-host-protected-store.mjs +2721 -0
- package/clients/core/hellolynk-host-transport-client.mjs +946 -0
- package/clients/core/hellolynk-host.mjs +2374 -0
- package/clients/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/clients/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/clients/core/hellolynk-inbox-read-state.mjs +11 -0
- package/clients/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/clients/core/hellolynk-instance-conversation.mjs +185 -0
- package/clients/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/clients/core/hellolynk-local-contacts.mjs +1228 -0
- package/clients/core/hellolynk-local-data-migration.mjs +766 -0
- package/clients/core/hellolynk-local-data-protection.mjs +747 -0
- package/clients/core/hellolynk-local-identity-trust.mjs +274 -0
- package/clients/core/hellolynk-local-messaging.mjs +3043 -0
- package/clients/core/hellolynk-local-peer-transport.mjs +103 -0
- package/clients/core/hellolynk-local-process-lock.mjs +157 -0
- package/clients/core/hellolynk-model-inbox.mjs +230 -0
- package/clients/core/hellolynk-model-notices.cjs +60 -0
- package/clients/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/clients/core/hellolynk-native-conversations.mjs +280 -0
- package/clients/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/clients/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/clients/core/hellolynk-native-notice.mjs +50 -0
- package/clients/core/hellolynk-native-reply-routes.mjs +96 -0
- package/clients/core/hellolynk-native-worker-lease.mjs +84 -0
- package/clients/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/clients/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/clients/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/clients/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/clients/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/clients/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/clients/core/hellolynk-platform-credential-store.mjs +773 -0
- package/clients/core/hellolynk-relay-client-cli.mjs +232 -0
- package/clients/core/hellolynk-release-retention.mjs +160 -0
- package/clients/core/hellolynk-runtime-safety.cjs +471 -0
- package/clients/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/clients/core/hellolynk-workbench-connection.mjs +234 -0
- package/clients/platform/windows/configure-agent.ps1 +281 -0
- package/clients/platform/windows/credential-store.ps1 +844 -0
- package/clients/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/clients/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/clients/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/clients/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/clients/workbench/contract.mjs +1314 -0
- package/clients/workbench/service.mjs +485 -0
- package/hellolynk-client-pack-manifest.json +854 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/integrations/openclaw-channel/dist/control-ui/index.js +63 -0
- package/integrations/openclaw-channel/index.mjs +5 -0
- package/integrations/openclaw-channel/openclaw.plugin.json +57 -0
- package/integrations/openclaw-channel/package.json +50 -0
- package/integrations/openclaw-channel/setup-entry.mjs +4 -0
- package/integrations/openclaw-channel/skills/hellolynk-messaging/SKILL.md +9 -0
- package/integrations/openclaw-channel/src/authorized-messaging-tools.mjs +423 -0
- package/integrations/openclaw-channel/src/channel-dispatch.mjs +657 -0
- package/integrations/openclaw-channel/src/channel.mjs +368 -0
- package/integrations/openclaw-channel/src/config-schema.mjs +33 -0
- package/integrations/openclaw-channel/src/control-ui.mjs +411 -0
- package/integrations/openclaw-channel/src/inbound-policy.mjs +44 -0
- package/integrations/openclaw-channel/src/native-origins.mjs +53 -0
- package/integrations/openclaw-channel/src/neutral-host-adapter.mjs +826 -0
- package/integrations/openclaw-channel/src/neutral-read-runtime.mjs +469 -0
- package/integrations/openclaw-channel/src/runtime-primitives.mjs +12 -0
- package/integrations/openclaw-channel/src/transcript-attribution.mjs +140 -0
- package/package.json +69 -4
- package/runtime/connect-activate.mjs +837 -0
- package/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/runtime/core/hellolynk-client-account.mjs +180 -0
- package/runtime/core/hellolynk-client-core.mjs +770 -0
- package/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/runtime/core/hellolynk-client-update.mjs +453 -0
- package/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/runtime/core/hellolynk-host.mjs +2374 -0
- package/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/runtime/neutral-host-bootstrap.mjs +57 -0
- package/runtime/neutral-host-release-entry.mjs +81 -0
- package/runtime/neutral-host-release-loader.mjs +156 -0
- package/runtime/neutral-host-release-manifest.json +302 -0
- package/runtime/neutral-host-release-pin.json +4 -0
- package/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/runtime/platform/windows/credential-store.ps1 +844 -0
- package/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/runtime/workbench/contract.mjs +1314 -0
- package/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/runtime/workbench/host-capabilities.mjs +80 -0
- package/runtime/workbench/i18n.mjs +1127 -0
- package/runtime/workbench/loopback-surface.mjs +740 -0
- package/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/runtime/workbench/service.mjs +485 -0
- package/runtime/workbench/state.mjs +421 -0
- package/runtime/workbench/transports.mjs +171 -0
- package/runtime/workbench/ui/app.mjs +6120 -0
- package/runtime/workbench/ui/connection-form.mjs +155 -0
- package/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/runtime/workbench/ui/http-transport.mjs +247 -0
- package/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/runtime/workbench/ui/icons/close.svg +3 -0
- package/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/runtime/workbench/ui/icons/search.svg +3 -0
- package/runtime/workbench/ui/icons/security.svg +3 -0
- package/runtime/workbench/ui/icons/settings.svg +3 -0
- package/runtime/workbench/ui/index.html +139 -0
- package/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/runtime/workbench/ui/styles.css +686 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
import { projectInboxReadState } from "../../../clients/core/hellolynk-inbox-read-state.mjs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createNativeInboxRuntime } from "../../../clients/core/hellolynk-native-inbox-runtime.mjs";
|
|
6
|
+
import { readNativeNoticeLocale } from "../../../clients/core/hellolynk-native-notice.mjs";
|
|
7
|
+
import { dispatchHelloLynkInbound, resolveHelloLynkInboundRoute } from "./channel-dispatch.mjs";
|
|
8
|
+
import { resolveOpenClawOrigin } from "./native-origins.mjs";
|
|
9
|
+
|
|
10
|
+
const STATE_CONTRACT = "hellolynk-openclaw-authorized-read-state-v1";
|
|
11
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,240}$/;
|
|
12
|
+
const NATIVE_TARGET_ID = /^[a-z0-9][a-z0-9_-]{0,63}:[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
|
13
|
+
|
|
14
|
+
export async function runOpenClawNeutralReadRuntime(options = {}) {
|
|
15
|
+
const runtime = createOpenClawNeutralReadRuntime(options);
|
|
16
|
+
return runtime.run();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createOpenClawNeutralReadRuntime(options = {}) {
|
|
20
|
+
const adapter = requireAdapter(options.adapter);
|
|
21
|
+
const account = requireAccount(options.account);
|
|
22
|
+
const ctx = options.channelContext || {};
|
|
23
|
+
const dispatch = options.dispatch || dispatchHelloLynkInbound;
|
|
24
|
+
const readNoticeLocale = options.readNoticeLocale || readNativeNoticeLocale;
|
|
25
|
+
const stateStore = options.stateStore || createFileReadStateStore(resolveStatePath(options, adapter, account));
|
|
26
|
+
requireStateStore(stateStore);
|
|
27
|
+
const pollIntervalMs = integer(options.pollIntervalMs, 5_000, 20, 60_000);
|
|
28
|
+
const wait = options.wait || abortableWait;
|
|
29
|
+
const log = options.log || ctx.log;
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const abort = () => controller.abort();
|
|
32
|
+
options.abortSignal?.addEventListener("abort", abort, { once: true });
|
|
33
|
+
if (options.abortSignal?.aborted) abort();
|
|
34
|
+
const origins = new Map(), active = new Set();
|
|
35
|
+
let running, notified = 0, legacySnapshot;
|
|
36
|
+
const legacyRecord = async key => {
|
|
37
|
+
if (!stateStore.snapshot) return stateStore.get(key);
|
|
38
|
+
legacySnapshot ||= stateStore.snapshot();
|
|
39
|
+
return (await legacySnapshot)[key] || null;
|
|
40
|
+
};
|
|
41
|
+
const currentScope = () => {
|
|
42
|
+
if (controller.signal.aborted || ["closed", "dead"].includes(adapter.state)) throw invalid("neutral_read_stopped");
|
|
43
|
+
return adapter.binding;
|
|
44
|
+
};
|
|
45
|
+
const unsent = () => Object.assign(invalid("neutral_read_not_submitted"), { nativeDispatchStarted: false });
|
|
46
|
+
const routeFor = (conversationKey, epoch = 0) => resolveHelloLynkInboundRoute(ctx, account,
|
|
47
|
+
{ conversationId: conversationKey, conversationType: "direct" }, ctx.channelRuntime, { sessionEpoch: epoch });
|
|
48
|
+
const readOrigin = async (_scope, summary) => {
|
|
49
|
+
try {
|
|
50
|
+
const origin = await (options.readOrigin || resolveOpenClawOrigin)(ctx, adapter, summary);
|
|
51
|
+
if (origin) origins.set(origin.threadId, origin);
|
|
52
|
+
return origin;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error?.code === "openclaw_reply_origin_unavailable") return null;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
async function inspectThread(id) {
|
|
59
|
+
if (active.has(id)) return { state: "present", thread: { status: "active" } };
|
|
60
|
+
if (options.inspectThread) return options.inspectThread(id);
|
|
61
|
+
const origin = origins.get(id);
|
|
62
|
+
let sessionKey = origin?.sessionKey || id;
|
|
63
|
+
let agentId = origin?.agentId || /^agent:([^:]+):/.exec(id)?.[1];
|
|
64
|
+
// A reserved reply can outlive the pending metadata entry. Resolve only the
|
|
65
|
+
// exact recorded native UUID; never redirect it to the agent's current chat.
|
|
66
|
+
if (!agentId && /^[a-z0-9_-]+:[a-f0-9-]{36}$/.test(id)) {
|
|
67
|
+
if (typeof ctx.hellolynkResolveSessionKeyById !== "function") throw invalid("neutral_read_session_state_unavailable");
|
|
68
|
+
const [agent, sessionId] = id.split(":");
|
|
69
|
+
agentId = agent;
|
|
70
|
+
sessionKey = await ctx.hellolynkResolveSessionKeyById?.({ agentId, sessionId,
|
|
71
|
+
storePath: ctx.channelRuntime.session.resolveStorePath(ctx.cfg?.session?.store, { agentId }) });
|
|
72
|
+
if (!sessionKey) return { state: "deleted" };
|
|
73
|
+
origins.set(id, { threadId: id, agentId, sessionId, sessionKey,
|
|
74
|
+
storePath: ctx.channelRuntime.session.resolveStorePath(ctx.cfg?.session?.store, { agentId }) });
|
|
75
|
+
}
|
|
76
|
+
if (!agentId || typeof ctx.hellolynkGetSessionEntry !== "function") throw invalid("neutral_read_session_state_unavailable");
|
|
77
|
+
const entry = await ctx.hellolynkGetSessionEntry({ agentId, sessionKey,
|
|
78
|
+
storePath: ctx.channelRuntime.session.resolveStorePath(ctx.cfg?.session?.store, { agentId }),
|
|
79
|
+
readConsistency: "latest", hydrateSkillPromptRefs: false });
|
|
80
|
+
if (entry === undefined) return { state: "deleted" };
|
|
81
|
+
if (!entry || typeof entry.sessionId !== "string") throw invalid("neutral_read_session_state_unavailable");
|
|
82
|
+
if (origins.get(id)?.sessionId && entry.sessionId !== origins.get(id).sessionId) return { state: "deleted" };
|
|
83
|
+
if (entry.archivedAt != null) return { state: "archived" };
|
|
84
|
+
if (typeof ctx.hellolynkActiveSessionRun !== "function") throw invalid("neutral_read_session_state_unavailable");
|
|
85
|
+
return { state: "present", sessionKey, nativeThreadId: `${agentId}:${entry.sessionId}`,
|
|
86
|
+
thread: { status: active.has(id) || ctx.hellolynkActiveSessionRun(sessionKey) ? "active" : "idle" } };
|
|
87
|
+
}
|
|
88
|
+
// The SDK's onExecutionStarted / onAgentRunStart acknowledges admission.
|
|
89
|
+
// Keep the native run alive, but don't make delivery wait for its completion.
|
|
90
|
+
function acceptedDispatch(id, message, input = {}) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
let accepted = false, started = false;
|
|
93
|
+
const deadline = setTimeout(() => reject(invalid("neutral_read_acceptance_unknown")), options.acceptanceTimeoutMs || 30_000);
|
|
94
|
+
const onAbort = () => reject(invalid("neutral_read_stopped"));
|
|
95
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
96
|
+
active.add(id);
|
|
97
|
+
const work = Promise.resolve().then(() => dispatch(ctx, account, message, {
|
|
98
|
+
adapter, ...input, abortSignal: controller.signal,
|
|
99
|
+
onNativeSubmissionStart() { started = true; },
|
|
100
|
+
onNativeAccepted() {
|
|
101
|
+
if (accepted) return;
|
|
102
|
+
accepted = true; clearTimeout(deadline);
|
|
103
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
104
|
+
notified++;
|
|
105
|
+
// The SDK has initialized this run's session before this callback.
|
|
106
|
+
// Persist its native UUID, never the reusable route key, as owner.
|
|
107
|
+
Promise.resolve().then(() => input.rememberNativeThread ? input.rememberNativeThread() : id)
|
|
108
|
+
.then(threadId => resolve({ ok: true, threadId }), reject);
|
|
109
|
+
},
|
|
110
|
+
}));
|
|
111
|
+
work.then(() => { if (!accepted) reject(invalid("neutral_read_acceptance_unknown")); }, error => {
|
|
112
|
+
if (!accepted) { if (!started) error.nativeDispatchStarted = false; reject(error); }
|
|
113
|
+
else log?.warn?.(`hellolynk native run ended: ${safeCode(error)}`);
|
|
114
|
+
}).finally(() => {
|
|
115
|
+
active.delete(id); clearTimeout(deadline);
|
|
116
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
async function contactRequests(scope) {
|
|
121
|
+
// Existing request notifications retain their ledger; ordinary messages no
|
|
122
|
+
// longer read bodies here or wait for claim/finish/task outcomes.
|
|
123
|
+
const result = await adapter.listAuthorizedContactRequests();
|
|
124
|
+
for (const item of result.requests || []) {
|
|
125
|
+
const summary = normalizeSummary("contact_request", item, account);
|
|
126
|
+
const key = processingKey(account.accountId, summary);
|
|
127
|
+
if (await stateStore.get(key)) continue;
|
|
128
|
+
const detail = await adapter.readAuthorizedContactRequest(summary.localId, summary.conversationKey);
|
|
129
|
+
assertDetailMatchesSummary("contact_request", detail, summary);
|
|
130
|
+
const route = routeFor(summary.conversationKey);
|
|
131
|
+
const lifecycle = await inspectThread(route.routeSessionKey);
|
|
132
|
+
if (lifecycle.state !== "deleted" && !(lifecycle.state === "present" && lifecycle.thread?.status === "idle")) continue;
|
|
133
|
+
const noticeLocale = await readNoticeLocale();
|
|
134
|
+
const current = currentScope();
|
|
135
|
+
if (Object.keys(scope).some(key => current[key] !== scope[key])) throw invalid("neutral_read_scope_changed");
|
|
136
|
+
await stateStore.put(key, stateRecord("unknown"));
|
|
137
|
+
try {
|
|
138
|
+
await acceptedDispatch(route.routeSessionKey, projectDispatchMessage("contact_request", detail, summary), { noticeLocale });
|
|
139
|
+
await stateStore.put(key, stateRecord("completed"));
|
|
140
|
+
} catch (error) { log?.warn?.(`hellolynk contact request held: ${safeCode(error)}`); }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const native = {
|
|
144
|
+
async listConversations(query = {}) {
|
|
145
|
+
if (query.targetConversationId) {
|
|
146
|
+
// Reusable session keys are carriers, not stable first-send addresses.
|
|
147
|
+
// Resolve an exact UUID independently of the full catalog.
|
|
148
|
+
const id = query.targetConversationId;
|
|
149
|
+
const valid = NATIVE_TARGET_ID.test(id);
|
|
150
|
+
const current = valid ? await inspectThread(id).catch(() => null) : null;
|
|
151
|
+
const state = !valid || current?.sessionKey?.includes(":subagent:") ? "deleted"
|
|
152
|
+
: ["archived", "deleted"].includes(current?.state) ? current.state
|
|
153
|
+
: current?.state === "present" ? current.thread?.status : "unavailable";
|
|
154
|
+
return { conversations: [{ targetConversationId: id, title: id, project: "", state }], coverage: "partial", exactTargetSupported: true };
|
|
155
|
+
}
|
|
156
|
+
if (typeof ctx.hellolynkListSessionEntries !== "function") throw invalid("openclaw_native_catalog_unavailable");
|
|
157
|
+
const configured = ctx.cfg?.agents?.list || [];
|
|
158
|
+
const agentIds = [...new Set([ctx.cfg?.agents?.defaults?.id || "main", ...configured.map(agent => agent.id)])];
|
|
159
|
+
const conversations = [];
|
|
160
|
+
for (const agentId of agentIds) {
|
|
161
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(agentId)) continue;
|
|
162
|
+
const storePath = ctx.channelRuntime.session.resolveStorePath(ctx.cfg?.session?.store, { agentId });
|
|
163
|
+
const rows = await ctx.hellolynkListSessionEntries({ agentId, storePath, readOnly: true, hydrateSkillPromptRefs: false, readConsistency: "latest" });
|
|
164
|
+
for (const { sessionKey, entry } of rows) {
|
|
165
|
+
if (!sessionKey.startsWith(`agent:${agentId}:`) || sessionKey.includes(":subagent:") || !NATIVE_TARGET_ID.test(`${agentId}:${entry.sessionId}`)) continue;
|
|
166
|
+
conversations.push({ targetConversationId: `${agentId}:${entry.sessionId}`, title: entry.label || entry.displayName || entry.subject || entry.sessionId,
|
|
167
|
+
project: configured.find(agent => agent.id === agentId)?.name || agentId,
|
|
168
|
+
state: entry.archivedAt != null ? "archived" : ctx.hellolynkActiveSessionRun?.(sessionKey) ? "active" : "idle" });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { conversations, coverage: "complete", exactTargetSupported: true };
|
|
172
|
+
},
|
|
173
|
+
beforeTick() { currentScope(); legacySnapshot = null; },
|
|
174
|
+
async configureScope() {
|
|
175
|
+
const result = await adapter.activateAuthorizedInbox();
|
|
176
|
+
if (result?.ok !== true || result.active !== true) throw invalid("neutral_read_activation_invalid");
|
|
177
|
+
},
|
|
178
|
+
async legacyDelivery(item) {
|
|
179
|
+
const old = await legacyRecord(processingKey(account.accountId, normalizeSummary("message", item, account)));
|
|
180
|
+
return old && ["inflight", "unknown", "completed"].includes(old.state) ? old.state === "completed" ? "sent" : "unknown" : null;
|
|
181
|
+
},
|
|
182
|
+
async legacyCarrier(key) {
|
|
183
|
+
const old = await legacyRecord(sessionStateKey(account.accountId, { conversationKey: key, localDelegateId: adapterDelegate(account) }));
|
|
184
|
+
return old && Number.isSafeInteger(old.epoch) ? routeFor(key, old.epoch).routeSessionKey : null;
|
|
185
|
+
},
|
|
186
|
+
async prepare(scope) {
|
|
187
|
+
try { await contactRequests(scope); } catch (error) { log?.warn?.(`hellolynk contact request list held: ${safeCode(error)}`); }
|
|
188
|
+
},
|
|
189
|
+
inspectThread,
|
|
190
|
+
canNotifyOrigin(value) { return value?.state === "present" && value.thread?.status === "idle"; },
|
|
191
|
+
async call(operation, args, context) {
|
|
192
|
+
if (operation === "list_threads") {
|
|
193
|
+
if (typeof ctx.hellolynkGetSessionEntry !== "function" && !options.inspectThread) throw invalid("neutral_read_session_state_unavailable");
|
|
194
|
+
// Round trip against the target, not a recent-conversations list.
|
|
195
|
+
await inspectThread(routeFor("hellolynk-connection-probe").routeSessionKey);
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
let submit;
|
|
199
|
+
try {
|
|
200
|
+
if (!await context.stillCurrent()) throw unsent();
|
|
201
|
+
const original = context.original;
|
|
202
|
+
const conversationKey = original ? context.batchId : context.target.slice("openclaw-conv:".length);
|
|
203
|
+
const epoch = parseInt(crypto.createHash("sha256").update(context.batchId).digest("hex").slice(0, 12), 16);
|
|
204
|
+
let id = args.threadId, createdRoute;
|
|
205
|
+
if (operation === "create_thread") {
|
|
206
|
+
createdRoute = routeFor(conversationKey, epoch);
|
|
207
|
+
id = createdRoute.routeSessionKey;
|
|
208
|
+
} else {
|
|
209
|
+
const state = await inspectThread(id);
|
|
210
|
+
if (state.state !== "present" || state.thread?.status !== "idle") throw unsent();
|
|
211
|
+
}
|
|
212
|
+
if (!await context.stillCurrent()) throw unsent();
|
|
213
|
+
const origin = origins.get(id);
|
|
214
|
+
if (original && !origin) throw unsent();
|
|
215
|
+
submit = () => acceptedDispatch(id, { messageType: "inbox_batch", messageId: context.batchId,
|
|
216
|
+
deliveryId: context.batchId, conversationId: conversationKey, conversationType: "direct",
|
|
217
|
+
text: args.prompt, timestamp: new Date().toISOString(), senderPrincipalDisplayName: "HelloLynk",
|
|
218
|
+
identityProvenanceState: "local_automatic_notice", attachments: [] },
|
|
219
|
+
{ origin: original ? origin : undefined, noticeLocale: context.noticeLocale,
|
|
220
|
+
...(!original ? { routeSessionKey: origin?.sessionKey || id } : {}),
|
|
221
|
+
...(createdRoute ? { rememberNativeThread: async () => {
|
|
222
|
+
const agentId = createdRoute.agentId, sessionKey = createdRoute.routeSessionKey;
|
|
223
|
+
const storePath = ctx.channelRuntime.session.resolveStorePath(ctx.cfg?.session?.store, { agentId });
|
|
224
|
+
const entry = await ctx.hellolynkGetSessionEntry({ agentId, sessionKey, storePath,
|
|
225
|
+
readConsistency: "latest", hydrateSkillPromptRefs: false });
|
|
226
|
+
const threadId = `${agentId}:${entry?.sessionId}`;
|
|
227
|
+
if (!NATIVE_TARGET_ID.test(threadId) || !await context.stillCurrent()) throw invalid("neutral_read_session_state_unavailable");
|
|
228
|
+
await context.rememberThread(threadId);
|
|
229
|
+
origins.set(threadId, { threadId, agentId, sessionKey, sessionId: entry.sessionId, storePath });
|
|
230
|
+
return threadId;
|
|
231
|
+
} } : {}) });
|
|
232
|
+
} catch (error) {
|
|
233
|
+
// Preparation cannot have submitted a native prompt. Keep the Host's
|
|
234
|
+
// registered ticket reusable; uncertainty starts inside acceptedDispatch.
|
|
235
|
+
throw Object.assign(invalid(safeCode(error)), { nativeDispatchStarted: false });
|
|
236
|
+
}
|
|
237
|
+
return submit();
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
const runtime = createNativeInboxRuntime({ adapterId: "openclaw", native, acquireLease: options.acquireLease,
|
|
241
|
+
readNoticeLocale,
|
|
242
|
+
adapter: { nativeTaskScope: currentScope, listPendingMessages: input => adapter.listPendingMessages(input), inboxBatch: (...args) => adapter.inboxBatch(...args) },
|
|
243
|
+
readReplyRoute: readOrigin, onError: event => { if (event.operation !== "directory") log?.warn?.(`hellolynk inbox held: ${event.code}`); } });
|
|
244
|
+
async function cycle() { const before = notified; await runtime.tick(); return { processed: notified - before, empty: notified === before }; }
|
|
245
|
+
async function close() { abort(); await runtime.close(); options.abortSignal?.removeEventListener("abort", abort); }
|
|
246
|
+
async function run() {
|
|
247
|
+
if (running) return running;
|
|
248
|
+
running = (async () => {
|
|
249
|
+
try {
|
|
250
|
+
while (!controller.signal.aborted && !["dead", "closed"].includes(adapter.state)) {
|
|
251
|
+
// Keep the existing cadence while delivery awaits native acceptance;
|
|
252
|
+
// the shared runtime serializes delivery and services only metadata.
|
|
253
|
+
void cycle().catch(error => { if (!controller.signal.aborted) log?.warn?.(`hellolynk inbox held: ${safeCode(error)}`); });
|
|
254
|
+
await wait(pollIntervalMs, controller.signal);
|
|
255
|
+
}
|
|
256
|
+
return adapter.state === "dead" ? "neutral_host_adapter_unavailable" : null;
|
|
257
|
+
} finally { await close(); }
|
|
258
|
+
})();
|
|
259
|
+
return running;
|
|
260
|
+
}
|
|
261
|
+
return Object.freeze({ cycle, run, close });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function createMemoryReadStateStore(seed = {}) {
|
|
265
|
+
const records = new Map(Object.entries(seed));
|
|
266
|
+
const legacyHeld = [...records.values()].some((record) => ["inflight", "unknown"].includes(record?.state));
|
|
267
|
+
return Object.freeze({
|
|
268
|
+
async legacyHeld() { return legacyHeld; },
|
|
269
|
+
async get(key) { return records.get(key) || null; },
|
|
270
|
+
async put(key, value) { records.set(key, structuredClone(value)); },
|
|
271
|
+
async delete(key) { records.delete(key); },
|
|
272
|
+
snapshot() { return Object.fromEntries(records); },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function createFileReadStateStore(filePath) {
|
|
277
|
+
const target = path.resolve(String(filePath || ""));
|
|
278
|
+
let pending = Promise.resolve();
|
|
279
|
+
function enqueue(operation) {
|
|
280
|
+
const result = pending.then(operation);
|
|
281
|
+
pending = result.catch(() => {});
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
async function read() {
|
|
285
|
+
try {
|
|
286
|
+
const value = JSON.parse(await fs.readFile(target, "utf8"));
|
|
287
|
+
if (value?.contract !== STATE_CONTRACT || !plain(value.records)) throw invalid("neutral_read_state_invalid");
|
|
288
|
+
if (!Object.hasOwn(value, "legacyHeld")) {
|
|
289
|
+
value.legacyHeld = Object.values(value.records).some((record) => ["inflight", "unknown"].includes(record?.state));
|
|
290
|
+
}
|
|
291
|
+
if (typeof value.legacyHeld !== "boolean") throw invalid("neutral_read_state_invalid");
|
|
292
|
+
return value;
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (error?.code === "ENOENT") return { contract: STATE_CONTRACT, records: {}, legacyHeld: false };
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
async function publish(value) {
|
|
299
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
300
|
+
const temp = `${target}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`;
|
|
301
|
+
let handle, owned = false;
|
|
302
|
+
try {
|
|
303
|
+
handle = await fs.open(temp, "wx", 0o600);
|
|
304
|
+
owned = true;
|
|
305
|
+
await handle.writeFile(JSON.stringify(value), "utf8");
|
|
306
|
+
await handle.close();
|
|
307
|
+
handle = null;
|
|
308
|
+
await fs.rename(temp, target);
|
|
309
|
+
} finally {
|
|
310
|
+
await handle?.close().catch(() => {});
|
|
311
|
+
// Never sweep other attempts or replace the original publication error.
|
|
312
|
+
if (owned) await fs.unlink(temp).catch(() => {});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return Object.freeze({
|
|
316
|
+
async snapshot() { await pending; return (await read()).records; },
|
|
317
|
+
async legacyHeld() { await pending; return (await read()).legacyHeld; },
|
|
318
|
+
async get(key) { await pending; return (await read()).records[key] || null; },
|
|
319
|
+
async put(key, record) {
|
|
320
|
+
return enqueue(async () => {
|
|
321
|
+
const value = await read();
|
|
322
|
+
value.records[key] = record;
|
|
323
|
+
await publish(value);
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
async delete(key) {
|
|
327
|
+
return enqueue(async () => {
|
|
328
|
+
const value = await read();
|
|
329
|
+
delete value.records[key];
|
|
330
|
+
await publish(value);
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function normalizeSummary(kind, value, account) {
|
|
337
|
+
if (!plain(value)) throw invalid("neutral_read_summary_invalid");
|
|
338
|
+
const localId = safeId(kind === "message" ? value.localMessageId : value.localRecordId, "neutral_read_local_id_invalid");
|
|
339
|
+
const conversationKey = safeId(value.conversationKey, "neutral_read_conversation_key_invalid");
|
|
340
|
+
const localDelegateId = safeId(value.localDelegateId, "neutral_read_local_delegate_invalid");
|
|
341
|
+
if (localDelegateId !== adapterDelegate(account)) throw invalid("neutral_read_local_delegate_mismatch");
|
|
342
|
+
const peerDelegateId = kind === "message" && value.peerDelegateId != null
|
|
343
|
+
? safeId(value.peerDelegateId, "neutral_read_peer_delegate_invalid") : null;
|
|
344
|
+
const contactId = kind === "message" && value.contactId != null
|
|
345
|
+
? safeId(value.contactId, "neutral_read_contact_invalid") : null;
|
|
346
|
+
const replyToClientMessageId = kind === "message" && value.replyToClientMessageId != null
|
|
347
|
+
? safeId(value.replyToClientMessageId, "neutral_read_reply_origin_invalid") : null;
|
|
348
|
+
return Object.freeze({ kind, localId, conversationKey, localDelegateId, peerDelegateId, contactId, replyToClientMessageId });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function assertDetailMatchesSummary(kind, detail, summary) {
|
|
352
|
+
// contactId is mutable contact-book metadata, not the message's route.
|
|
353
|
+
// Deleting/re-adding a saved contact must not revoke access to owned mail.
|
|
354
|
+
// Replies use the latest Host detail and still require send authorization.
|
|
355
|
+
if (!plain(detail)
|
|
356
|
+
|| detail[kind === "message" ? "localMessageId" : "localRecordId"] !== summary.localId
|
|
357
|
+
|| detail.conversationKey !== summary.conversationKey
|
|
358
|
+
|| detail.localDelegateId !== summary.localDelegateId
|
|
359
|
+
|| detail.externalContentIsUntrustedData !== true
|
|
360
|
+
|| (kind === "message" ? !projectInboxReadState(detail) : detail.readDoesNotMarkRead !== true)
|
|
361
|
+
|| detail.source !== (kind === "message" ? "hellolynk_external_message" : "hellolynk_contact_request")
|
|
362
|
+
|| (kind === "message" && summary.peerDelegateId && detail.peerDelegateId !== summary.peerDelegateId)
|
|
363
|
+
|| (kind === "message" && (detail.replyToClientMessageId ?? null) !== summary.replyToClientMessageId)) {
|
|
364
|
+
throw invalid("neutral_read_detail_binding_invalid");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function projectDispatchMessage(kind, detail, summary) {
|
|
369
|
+
const replyTarget = kind === "message"
|
|
370
|
+
&& [detail.peerPrincipalId, detail.peerDelegateId, detail.contactId].every((value) => typeof value === "string" && SAFE_ID.test(value))
|
|
371
|
+
? Object.freeze({
|
|
372
|
+
localMessageId: summary.localId,
|
|
373
|
+
conversationKey: summary.conversationKey,
|
|
374
|
+
localDelegateId: summary.localDelegateId,
|
|
375
|
+
peerPrincipalId: detail.peerPrincipalId,
|
|
376
|
+
peerDelegateId: detail.peerDelegateId,
|
|
377
|
+
contactId: detail.contactId,
|
|
378
|
+
})
|
|
379
|
+
: null;
|
|
380
|
+
const sender = kind === "message" ? {
|
|
381
|
+
principalId: detail.peerPrincipalId,
|
|
382
|
+
delegateId: detail.peerDelegateId || detail.senderDelegateId,
|
|
383
|
+
displayName: detail.displayName,
|
|
384
|
+
} : {
|
|
385
|
+
principalId: detail.sender?.hellolynkId,
|
|
386
|
+
delegateId: detail.peerDelegateId || "hellolynk:unknown-delegate",
|
|
387
|
+
displayName: detail.sender?.verifiedDisplayName,
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
deliveryId: summary.localId,
|
|
391
|
+
messageId: summary.localId,
|
|
392
|
+
conversationId: summary.conversationKey,
|
|
393
|
+
conversationType: "direct",
|
|
394
|
+
messageType: kind === "message" ? (detail.messageType || "text") : "contact_request",
|
|
395
|
+
text: kind === "message" ? String(detail.text ?? "") : String(detail.introText ?? ""),
|
|
396
|
+
timestamp: detail.createdAt || detail.receivedAt,
|
|
397
|
+
senderPrincipalId: safeId(sender.principalId, "neutral_read_sender_principal_invalid"),
|
|
398
|
+
senderDelegateId: safeId(sender.delegateId, "neutral_read_sender_delegate_invalid"),
|
|
399
|
+
senderPrincipalDisplayName: String(sender.displayName || ""),
|
|
400
|
+
senderDelegateDisplayName: kind === "message" ? String(detail.peerInstanceDisplayName || sender.delegateId || "") : "",
|
|
401
|
+
identityProvenanceState: kind === "message" || detail.peerDelegateId
|
|
402
|
+
? "host_authorized_instance_binding"
|
|
403
|
+
: "host_authorized_principal_delegate_unknown",
|
|
404
|
+
attachments: [],
|
|
405
|
+
raw: { source: detail.source },
|
|
406
|
+
replyTarget,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function stateRecord(state, sessionEpoch) {
|
|
411
|
+
return Object.freeze({ state, ...(sessionEpoch !== undefined ? { sessionEpoch } : {}), updatedAt: new Date().toISOString() });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function sessionRecord(state, epoch) {
|
|
415
|
+
return Object.freeze({ state, epoch, updatedAt: new Date().toISOString() });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function processingKey(accountId, summary) {
|
|
419
|
+
return crypto.createHash("sha256").update(JSON.stringify([
|
|
420
|
+
accountId, summary.localDelegateId, summary.conversationKey, summary.kind, summary.localId,
|
|
421
|
+
])).digest("hex");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function sessionStateKey(accountId, summary) {
|
|
425
|
+
const hash = crypto.createHash("sha256").update(JSON.stringify([
|
|
426
|
+
accountId, summary.localDelegateId, summary.conversationKey,
|
|
427
|
+
])).digest("hex");
|
|
428
|
+
return `session:${hash}`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function resolveStatePath(options, adapter, account) {
|
|
432
|
+
const explicit = options.statePath || options.channelContext?.hellolynkReadStatePath;
|
|
433
|
+
if (explicit) return explicit;
|
|
434
|
+
const root = String(adapter.binding?.runtimeRoot || "");
|
|
435
|
+
if (!path.isAbsolute(root)) throw invalid("neutral_read_state_path_required");
|
|
436
|
+
const accountHash = crypto.createHash("sha256").update(account.accountId).digest("hex").slice(0, 24);
|
|
437
|
+
return path.join(root, "openclaw", `authorized-read-${accountHash}.json`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function requireAdapter(value) {
|
|
441
|
+
for (const method of ["activateAuthorizedInbox", "listPendingMessages", "inboxBatch", "listAuthorizedContactRequests", "readAuthorizedContactRequest"]) {
|
|
442
|
+
if (typeof value?.[method] !== "function") throw invalid("neutral_read_adapter_invalid");
|
|
443
|
+
}
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
function requireStateStore(value) {
|
|
447
|
+
for (const method of ["legacyHeld", "get", "put", "delete"]) {
|
|
448
|
+
if (typeof value?.[method] !== "function") throw invalid("neutral_read_state_store_invalid");
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
function requireAccount(value) {
|
|
452
|
+
if (!value || !safeId(value.accountId, "neutral_read_account_invalid")) throw invalid("neutral_read_account_invalid");
|
|
453
|
+
return value;
|
|
454
|
+
}
|
|
455
|
+
function adapterDelegate(account) { return safeId(account.localDelegateId || account.delegateId || account.binding?.delegateId || account.__delegateId, "neutral_read_account_delegate_required"); }
|
|
456
|
+
function safeId(value, code) { const text = String(value || ""); if (!SAFE_ID.test(text)) throw invalid(code); return text; }
|
|
457
|
+
function integer(value, fallback, min, max) { const n = Number(value ?? fallback); if (!Number.isSafeInteger(n) || n < min || n > max) throw invalid("neutral_read_interval_invalid"); return n; }
|
|
458
|
+
function plain(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); }
|
|
459
|
+
function invalid(code) { const error = new Error(code); error.code = code; return error; }
|
|
460
|
+
function safeCode(error) { const code = String(error?.code || "neutral_read_failed"); return /^[a-z][a-z0-9_]{2,127}$/.test(code) ? code : "neutral_read_failed"; }
|
|
461
|
+
function isAuthorizationFailure(error) { return /(authorization|authorized|permission|revoked|owner_mismatch|binding_mismatch)/.test(safeCode(error)); }
|
|
462
|
+
function abortableWait(ms, signal) {
|
|
463
|
+
if (signal?.aborted) return Promise.resolve();
|
|
464
|
+
return new Promise((resolve) => {
|
|
465
|
+
const timer = setTimeout(done, ms); timer.unref?.();
|
|
466
|
+
function done() { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); }
|
|
467
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
468
|
+
});
|
|
469
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export class HelloLynkContractError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "HelloLynkContractError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function sha256Hex(value) {
|
|
11
|
+
return createHash("sha256").update(value).digest("hex");
|
|
12
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const HELLOLYNK_SESSION_KEY_RE = /^agent:[^:\s]+:hellolynk:/i;
|
|
2
|
+
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
|
3
|
+
const MAX_PENDING_SESSIONS = 256;
|
|
4
|
+
const MAX_PENDING_PER_SESSION = 32;
|
|
5
|
+
|
|
6
|
+
const pendingBySession = new Map();
|
|
7
|
+
|
|
8
|
+
export function registerHelloLynkTranscriptAttribution({
|
|
9
|
+
sessionKey,
|
|
10
|
+
body,
|
|
11
|
+
senderLabel,
|
|
12
|
+
originalSession = false,
|
|
13
|
+
now = Date.now(),
|
|
14
|
+
ttlMs = DEFAULT_TTL_MS
|
|
15
|
+
}) {
|
|
16
|
+
const normalizedSessionKey = normalizeSessionKey(sessionKey, originalSession);
|
|
17
|
+
const normalizedBody = String(body ?? "");
|
|
18
|
+
const normalizedSenderLabel = normalizeSenderLabel(senderLabel);
|
|
19
|
+
const normalizedNow = requireFiniteNumber(now, "hellolynk transcript attribution time is invalid");
|
|
20
|
+
const normalizedTtl = requireFiniteNumber(ttlMs, "hellolynk transcript attribution ttl is invalid");
|
|
21
|
+
if (!normalizedBody) throw new Error("hellolynk transcript attribution body is missing");
|
|
22
|
+
if (normalizedTtl <= 0 || normalizedTtl > DEFAULT_TTL_MS) {
|
|
23
|
+
throw new Error("hellolynk transcript attribution ttl is out of range");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pruneExpired(normalizedNow);
|
|
27
|
+
let records = pendingBySession.get(normalizedSessionKey);
|
|
28
|
+
if (!records) {
|
|
29
|
+
if (pendingBySession.size >= MAX_PENDING_SESSIONS) {
|
|
30
|
+
throw new Error("hellolynk transcript attribution session capacity exceeded");
|
|
31
|
+
}
|
|
32
|
+
records = [];
|
|
33
|
+
pendingBySession.set(normalizedSessionKey, records);
|
|
34
|
+
}
|
|
35
|
+
if (records.length >= MAX_PENDING_PER_SESSION) {
|
|
36
|
+
throw new Error("hellolynk transcript attribution per-session capacity exceeded");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const token = Symbol("hellolynk-transcript-attribution");
|
|
40
|
+
records.push({
|
|
41
|
+
token,
|
|
42
|
+
body: normalizedBody,
|
|
43
|
+
senderLabel: normalizedSenderLabel,
|
|
44
|
+
expiresAt: normalizedNow + normalizedTtl
|
|
45
|
+
});
|
|
46
|
+
return () => removeRecord(normalizedSessionKey, token);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function applyHelloLynkTranscriptAttribution(event, hookContext, now = Date.now()) {
|
|
50
|
+
const message = event?.message;
|
|
51
|
+
if (!message || typeof message !== "object" || String(message.role || "").toLowerCase() !== "user") {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof message.senderLabel === "string" && message.senderLabel.trim()) return undefined;
|
|
55
|
+
|
|
56
|
+
const sessionKey = String(hookContext?.sessionKey || event?.sessionKey || "").trim();
|
|
57
|
+
if (!/^agent:[^:\s]+:[^\s]+$/i.test(sessionKey)) return undefined;
|
|
58
|
+
const records = pendingBySession.get(sessionKey);
|
|
59
|
+
if (!records?.length) return undefined;
|
|
60
|
+
|
|
61
|
+
const normalizedNow = requireFiniteNumber(now, "hellolynk transcript attribution hook time is invalid");
|
|
62
|
+
const messageTexts = collectMessageTexts(message);
|
|
63
|
+
let matchIndex = -1;
|
|
64
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
65
|
+
const record = records[index];
|
|
66
|
+
if (record.expiresAt <= normalizedNow) continue;
|
|
67
|
+
if (messageTexts.includes(record.body)) {
|
|
68
|
+
matchIndex = index;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
pruneSession(sessionKey, normalizedNow);
|
|
73
|
+
if (matchIndex < 0) return undefined;
|
|
74
|
+
|
|
75
|
+
const currentRecords = pendingBySession.get(sessionKey);
|
|
76
|
+
const matched = currentRecords?.find((record) => messageTexts.includes(record.body) && record.expiresAt > normalizedNow);
|
|
77
|
+
if (!matched) return undefined;
|
|
78
|
+
removeRecord(sessionKey, matched.token);
|
|
79
|
+
return {
|
|
80
|
+
message: {
|
|
81
|
+
...message,
|
|
82
|
+
senderLabel: matched.senderLabel
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function collectMessageTexts(message) {
|
|
88
|
+
const values = [];
|
|
89
|
+
if (typeof message.content === "string") values.push(message.content);
|
|
90
|
+
if (Array.isArray(message.content)) {
|
|
91
|
+
for (const block of message.content) {
|
|
92
|
+
if (!block || typeof block !== "object") continue;
|
|
93
|
+
if (typeof block.text === "string") values.push(block.text);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (typeof message.text === "string") values.push(message.text);
|
|
97
|
+
return values;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeSessionKey(value, originalSession) {
|
|
101
|
+
const sessionKey = String(value || "").trim();
|
|
102
|
+
if (!(originalSession === true ? /^agent:[^:\s]+:[^\s]+$/i : HELLOLYNK_SESSION_KEY_RE).test(sessionKey) || sessionKey.length > 1024 || /[\r\n\u0000-\u001f\u007f]/.test(sessionKey)) {
|
|
103
|
+
throw new Error("hellolynk transcript attribution session key is invalid");
|
|
104
|
+
}
|
|
105
|
+
return sessionKey;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function normalizeSenderLabel(value) {
|
|
109
|
+
const senderLabel = String(value || "").trim();
|
|
110
|
+
if (!senderLabel || senderLabel.length > 512 || /[\r\n\u0000-\u001f\u007f]/.test(senderLabel)) {
|
|
111
|
+
throw new Error("hellolynk transcript attribution sender label is invalid");
|
|
112
|
+
}
|
|
113
|
+
return senderLabel;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function requireFiniteNumber(value, errorMessage) {
|
|
117
|
+
const number = Number(value);
|
|
118
|
+
if (!Number.isFinite(number)) throw new Error(errorMessage);
|
|
119
|
+
return number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pruneExpired(now) {
|
|
123
|
+
for (const sessionKey of pendingBySession.keys()) pruneSession(sessionKey, now);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pruneSession(sessionKey, now) {
|
|
127
|
+
const records = pendingBySession.get(sessionKey);
|
|
128
|
+
if (!records) return;
|
|
129
|
+
const active = records.filter((record) => record.expiresAt > now);
|
|
130
|
+
if (active.length) pendingBySession.set(sessionKey, active);
|
|
131
|
+
else pendingBySession.delete(sessionKey);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function removeRecord(sessionKey, token) {
|
|
135
|
+
const records = pendingBySession.get(sessionKey);
|
|
136
|
+
if (!records) return;
|
|
137
|
+
const active = records.filter((record) => record.token !== token);
|
|
138
|
+
if (active.length) pendingBySession.set(sessionKey, active);
|
|
139
|
+
else pendingBySession.delete(sessionKey);
|
|
140
|
+
}
|