@ynhcj/xiaoyi-channel 0.0.170-next → 0.0.171-next
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/src/acp-session-binding.d.ts +37 -0
- package/dist/src/acp-session-binding.js +237 -0
- package/dist/src/bot.js +10 -1
- package/dist/src/channel.js +5 -0
- package/dist/src/cspl/call_api.js +2 -0
- package/dist/src/cspl/sentinel_hook.js +8 -7
- package/dist/src/log-reporter/config-loader.d.ts +11 -0
- package/dist/src/log-reporter/config-loader.js +68 -0
- package/dist/src/log-reporter/cursor-store.d.ts +5 -0
- package/dist/src/log-reporter/cursor-store.js +26 -0
- package/dist/src/log-reporter/index.d.ts +10 -0
- package/dist/src/log-reporter/index.js +77 -0
- package/dist/src/log-reporter/reporter.d.ts +6 -0
- package/dist/src/log-reporter/reporter.js +17 -0
- package/dist/src/log-reporter/scanner.d.ts +6 -0
- package/dist/src/log-reporter/scanner.js +82 -0
- package/dist/src/log-reporter/types.d.ts +59 -0
- package/dist/src/log-reporter/types.js +2 -0
- package/dist/src/log-reporter/uploader.d.ts +6 -0
- package/dist/src/log-reporter/uploader.js +32 -0
- package/dist/src/memory-query-handler.d.ts +1 -0
- package/dist/src/memory-query-handler.js +250 -0
- package/dist/src/monitor.js +27 -0
- package/dist/src/parser.d.ts +12 -0
- package/dist/src/parser.js +32 -0
- package/dist/src/provider.js +21 -2
- package/dist/src/tools/create-all-tools.js +5 -10
- package/dist/src/tools/session-manager.d.ts +4 -0
- package/dist/src/websocket.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type BindingTargetKind } from "openclaw/plugin-sdk/conversation-runtime";
|
|
2
|
+
type XyBindingTargetKind = "subagent" | "acp";
|
|
3
|
+
type XyAcpBindingRecord = {
|
|
4
|
+
accountId: string;
|
|
5
|
+
conversationId: string;
|
|
6
|
+
parentConversationId?: string;
|
|
7
|
+
deliveryTo?: string;
|
|
8
|
+
targetKind: XyBindingTargetKind;
|
|
9
|
+
targetSessionKey: string;
|
|
10
|
+
agentId?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
boundBy?: string;
|
|
13
|
+
boundAt: number;
|
|
14
|
+
lastActivityAt: number;
|
|
15
|
+
};
|
|
16
|
+
type XyAcpBindingManager = {
|
|
17
|
+
accountId: string;
|
|
18
|
+
getByConversationId: (conversationId: string) => XyAcpBindingRecord | undefined;
|
|
19
|
+
listBySessionKey: (targetSessionKey: string) => XyAcpBindingRecord[];
|
|
20
|
+
bindConversation: (params: {
|
|
21
|
+
conversationId: string;
|
|
22
|
+
parentConversationId?: string;
|
|
23
|
+
targetKind: BindingTargetKind;
|
|
24
|
+
targetSessionKey: string;
|
|
25
|
+
metadata?: Record<string, unknown>;
|
|
26
|
+
}) => XyAcpBindingRecord | null;
|
|
27
|
+
touchConversation: (conversationId: string, at?: number) => XyAcpBindingRecord | null;
|
|
28
|
+
unbindConversation: (conversationId: string) => XyAcpBindingRecord | null;
|
|
29
|
+
unbindBySessionKey: (targetSessionKey: string) => XyAcpBindingRecord[];
|
|
30
|
+
stop: () => void;
|
|
31
|
+
};
|
|
32
|
+
export declare function createXyAcpBindingManager(params: {
|
|
33
|
+
accountId?: string;
|
|
34
|
+
cfg: any;
|
|
35
|
+
}): XyAcpBindingManager;
|
|
36
|
+
export declare function getXyAcpBindingManager(accountId?: string): XyAcpBindingManager | null;
|
|
37
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// ACP Session Binding Adapter for xiaoyi-channel.
|
|
2
|
+
// Follows the feishu thread-bindings.ts pattern.
|
|
3
|
+
//
|
|
4
|
+
// Maps A2A sessionId (stable conversation identifier) to ACP/subagent
|
|
5
|
+
// session keys so that openclaw can bind spawned sessions to the
|
|
6
|
+
// current xiaoyi conversation.
|
|
7
|
+
//
|
|
8
|
+
// Key design: xiaoyi-channel only supports `placement: "current"` —
|
|
9
|
+
// it cannot create child threads (unlike Discord). All spawned sessions
|
|
10
|
+
// are bound to the current A2A conversation identified by sessionId.
|
|
11
|
+
// NOTE: Using `any` for cfg type to avoid version mismatch between
|
|
12
|
+
// local and global openclaw installs (auth.profiles.aws-sdk union).
|
|
13
|
+
import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, resolveThreadBindingConversationIdFromBindingId, registerSessionBindingAdapter, unregisterSessionBindingAdapter, } from "openclaw/plugin-sdk/conversation-runtime";
|
|
14
|
+
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
|
15
|
+
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
16
|
+
import { logger } from "./utils/logger.js";
|
|
17
|
+
// ─── Global state (survives module dedup) ─────────────────────
|
|
18
|
+
const XY_ACP_BINDINGS_KEY = Symbol.for("openclaw.xyAcpBindingsState");
|
|
19
|
+
let state;
|
|
20
|
+
function getState() {
|
|
21
|
+
if (!state) {
|
|
22
|
+
const globalStore = globalThis;
|
|
23
|
+
state = globalStore[XY_ACP_BINDINGS_KEY] ?? {
|
|
24
|
+
managersByAccountId: new Map(),
|
|
25
|
+
bindingsByAccountConversation: new Map(),
|
|
26
|
+
};
|
|
27
|
+
globalStore[XY_ACP_BINDINGS_KEY] = state;
|
|
28
|
+
}
|
|
29
|
+
return state;
|
|
30
|
+
}
|
|
31
|
+
function resolveBindingKey(params) {
|
|
32
|
+
return `${params.accountId}:${params.conversationId}`;
|
|
33
|
+
}
|
|
34
|
+
// ─── Kind conversion ──────────────────────────────────────────
|
|
35
|
+
function toSessionBindingTargetKind(raw) {
|
|
36
|
+
return raw === "subagent" ? "subagent" : "session";
|
|
37
|
+
}
|
|
38
|
+
function toXyTargetKind(raw) {
|
|
39
|
+
return raw === "subagent" ? "subagent" : "acp";
|
|
40
|
+
}
|
|
41
|
+
// ─── Record conversion ────────────────────────────────────────
|
|
42
|
+
function toSessionBindingRecord(record, defaults) {
|
|
43
|
+
const idleExpiresAt = defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined;
|
|
44
|
+
const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined;
|
|
45
|
+
const expiresAt = idleExpiresAt != null && maxAgeExpiresAt != null
|
|
46
|
+
? Math.min(idleExpiresAt, maxAgeExpiresAt)
|
|
47
|
+
: (idleExpiresAt ?? maxAgeExpiresAt);
|
|
48
|
+
return {
|
|
49
|
+
bindingId: resolveBindingKey({
|
|
50
|
+
accountId: record.accountId,
|
|
51
|
+
conversationId: record.conversationId,
|
|
52
|
+
}),
|
|
53
|
+
targetSessionKey: record.targetSessionKey,
|
|
54
|
+
targetKind: toSessionBindingTargetKind(record.targetKind),
|
|
55
|
+
conversation: {
|
|
56
|
+
channel: "xiaoyi-channel",
|
|
57
|
+
accountId: record.accountId,
|
|
58
|
+
conversationId: record.conversationId,
|
|
59
|
+
parentConversationId: record.parentConversationId,
|
|
60
|
+
},
|
|
61
|
+
status: "active",
|
|
62
|
+
boundAt: record.boundAt,
|
|
63
|
+
expiresAt,
|
|
64
|
+
metadata: {
|
|
65
|
+
agentId: record.agentId,
|
|
66
|
+
label: record.label,
|
|
67
|
+
boundBy: record.boundBy,
|
|
68
|
+
deliveryTo: record.deliveryTo,
|
|
69
|
+
lastActivityAt: record.lastActivityAt,
|
|
70
|
+
idleTimeoutMs: defaults.idleTimeoutMs,
|
|
71
|
+
maxAgeMs: defaults.maxAgeMs,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// ─── Manager factory ──────────────────────────────────────────
|
|
76
|
+
export function createXyAcpBindingManager(params) {
|
|
77
|
+
const accountId = normalizeAccountId(params.accountId);
|
|
78
|
+
const existing = getState().managersByAccountId.get(accountId);
|
|
79
|
+
if (existing) {
|
|
80
|
+
return existing;
|
|
81
|
+
}
|
|
82
|
+
const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({
|
|
83
|
+
cfg: params.cfg,
|
|
84
|
+
channel: "xiaoyi-channel",
|
|
85
|
+
accountId,
|
|
86
|
+
});
|
|
87
|
+
const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({
|
|
88
|
+
cfg: params.cfg,
|
|
89
|
+
channel: "xiaoyi-channel",
|
|
90
|
+
accountId,
|
|
91
|
+
});
|
|
92
|
+
const log = logger.withContext("", "");
|
|
93
|
+
const manager = {
|
|
94
|
+
accountId,
|
|
95
|
+
getByConversationId: (conversationId) => getState().bindingsByAccountConversation.get(resolveBindingKey({ accountId, conversationId })),
|
|
96
|
+
listBySessionKey: (targetSessionKey) => [...getState().bindingsByAccountConversation.values()].filter((record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey),
|
|
97
|
+
bindConversation: ({ conversationId, parentConversationId, targetKind, targetSessionKey, metadata, }) => {
|
|
98
|
+
const normalizedConversationId = conversationId.trim();
|
|
99
|
+
const normalizedTargetSessionKey = targetSessionKey.trim();
|
|
100
|
+
if (!normalizedConversationId || !normalizedTargetSessionKey) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const existingLocal = getState().bindingsByAccountConversation.get(resolveBindingKey({ accountId, conversationId: normalizedConversationId }));
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const record = {
|
|
106
|
+
accountId,
|
|
107
|
+
conversationId: normalizedConversationId,
|
|
108
|
+
parentConversationId: normalizeOptionalString(parentConversationId) ?? existingLocal?.parentConversationId,
|
|
109
|
+
deliveryTo: typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim()
|
|
110
|
+
? metadata.deliveryTo.trim()
|
|
111
|
+
: existingLocal?.deliveryTo,
|
|
112
|
+
targetKind: toXyTargetKind(targetKind),
|
|
113
|
+
targetSessionKey: normalizedTargetSessionKey,
|
|
114
|
+
agentId: typeof metadata?.agentId === "string" && metadata.agentId.trim()
|
|
115
|
+
? metadata.agentId.trim()
|
|
116
|
+
: (existingLocal?.agentId ?? resolveAgentIdFromSessionKey(normalizedTargetSessionKey)),
|
|
117
|
+
label: typeof metadata?.label === "string" && metadata.label.trim()
|
|
118
|
+
? metadata.label.trim()
|
|
119
|
+
: existingLocal?.label,
|
|
120
|
+
boundBy: typeof metadata?.boundBy === "string" && metadata.boundBy.trim()
|
|
121
|
+
? metadata.boundBy.trim()
|
|
122
|
+
: existingLocal?.boundBy,
|
|
123
|
+
boundAt: now,
|
|
124
|
+
lastActivityAt: now,
|
|
125
|
+
};
|
|
126
|
+
getState().bindingsByAccountConversation.set(resolveBindingKey({ accountId, conversationId: normalizedConversationId }), record);
|
|
127
|
+
log.log(`[XY-ACP-BIND] Bound ${targetKind} session ${normalizedTargetSessionKey.slice(0, 30)} to conversation ${normalizedConversationId.slice(0, 12)}`);
|
|
128
|
+
return record;
|
|
129
|
+
},
|
|
130
|
+
touchConversation: (conversationId, at = Date.now()) => {
|
|
131
|
+
const key = resolveBindingKey({ accountId, conversationId });
|
|
132
|
+
const existingRecord = getState().bindingsByAccountConversation.get(key);
|
|
133
|
+
if (!existingRecord) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const updated = { ...existingRecord, lastActivityAt: at };
|
|
137
|
+
getState().bindingsByAccountConversation.set(key, updated);
|
|
138
|
+
return updated;
|
|
139
|
+
},
|
|
140
|
+
unbindConversation: (conversationId) => {
|
|
141
|
+
const key = resolveBindingKey({ accountId, conversationId });
|
|
142
|
+
const existingRecord = getState().bindingsByAccountConversation.get(key);
|
|
143
|
+
if (!existingRecord) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
getState().bindingsByAccountConversation.delete(key);
|
|
147
|
+
return existingRecord;
|
|
148
|
+
},
|
|
149
|
+
unbindBySessionKey: (targetSessionKey) => {
|
|
150
|
+
const removed = [];
|
|
151
|
+
for (const record of getState().bindingsByAccountConversation.values()) {
|
|
152
|
+
if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
getState().bindingsByAccountConversation.delete(resolveBindingKey({ accountId, conversationId: record.conversationId }));
|
|
156
|
+
removed.push(record);
|
|
157
|
+
}
|
|
158
|
+
return removed;
|
|
159
|
+
},
|
|
160
|
+
stop: () => {
|
|
161
|
+
for (const key of getState().bindingsByAccountConversation.keys()) {
|
|
162
|
+
if (key.startsWith(`${accountId}:`)) {
|
|
163
|
+
getState().bindingsByAccountConversation.delete(key);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
getState().managersByAccountId.delete(accountId);
|
|
167
|
+
unregisterSessionBindingAdapter({
|
|
168
|
+
channel: "xiaoyi-channel",
|
|
169
|
+
accountId,
|
|
170
|
+
adapter: sessionBindingAdapter,
|
|
171
|
+
});
|
|
172
|
+
log.log(`[XY-ACP-BIND] Stopped binding manager for account ${accountId}`);
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
const sessionBindingAdapter = {
|
|
176
|
+
channel: "xiaoyi-channel",
|
|
177
|
+
accountId,
|
|
178
|
+
capabilities: {
|
|
179
|
+
placements: ["current"],
|
|
180
|
+
},
|
|
181
|
+
bind: async (input) => {
|
|
182
|
+
if (input.conversation.channel !== "xiaoyi-channel" || input.placement === "child") {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const bound = manager.bindConversation({
|
|
186
|
+
conversationId: input.conversation.conversationId,
|
|
187
|
+
parentConversationId: input.conversation.parentConversationId,
|
|
188
|
+
targetKind: input.targetKind,
|
|
189
|
+
targetSessionKey: input.targetSessionKey,
|
|
190
|
+
metadata: input.metadata,
|
|
191
|
+
});
|
|
192
|
+
return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null;
|
|
193
|
+
},
|
|
194
|
+
listBySession: (targetSessionKey) => manager
|
|
195
|
+
.listBySessionKey(targetSessionKey)
|
|
196
|
+
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })),
|
|
197
|
+
resolveByConversation: (ref) => {
|
|
198
|
+
if (ref.channel !== "xiaoyi-channel") {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const found = manager.getByConversationId(ref.conversationId);
|
|
202
|
+
return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null;
|
|
203
|
+
},
|
|
204
|
+
touch: (bindingId, at) => {
|
|
205
|
+
const conversationId = resolveThreadBindingConversationIdFromBindingId({
|
|
206
|
+
accountId,
|
|
207
|
+
bindingId,
|
|
208
|
+
});
|
|
209
|
+
if (conversationId) {
|
|
210
|
+
manager.touchConversation(conversationId, at);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
unbind: async (input) => {
|
|
214
|
+
if (input.targetSessionKey?.trim()) {
|
|
215
|
+
return manager
|
|
216
|
+
.unbindBySessionKey(input.targetSessionKey.trim())
|
|
217
|
+
.map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs }));
|
|
218
|
+
}
|
|
219
|
+
const conversationId = resolveThreadBindingConversationIdFromBindingId({
|
|
220
|
+
accountId,
|
|
221
|
+
bindingId: input.bindingId,
|
|
222
|
+
});
|
|
223
|
+
if (!conversationId) {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
const removed = manager.unbindConversation(conversationId);
|
|
227
|
+
return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : [];
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
registerSessionBindingAdapter(sessionBindingAdapter);
|
|
231
|
+
getState().managersByAccountId.set(accountId, manager);
|
|
232
|
+
log.log(`[XY-ACP-BIND] Created binding manager for account ${accountId} (idleTimeout=${idleTimeoutMs}ms, maxAge=${maxAgeMs}ms)`);
|
|
233
|
+
return manager;
|
|
234
|
+
}
|
|
235
|
+
export function getXyAcpBindingManager(accountId) {
|
|
236
|
+
return getState().managersByAccountId.get(normalizeAccountId(accountId)) ?? null;
|
|
237
|
+
}
|
package/dist/src/bot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { updateSessionStoreEntry, updateSessionStore, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
|
|
2
2
|
import { getXYRuntime } from "./runtime.js";
|
|
3
3
|
import { createXYReplyDispatcher } from "./reply-dispatcher.js";
|
|
4
|
-
import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractModelName, extractTriggerData, extractRunCrossTaskContext } from "./parser.js";
|
|
4
|
+
import { parseA2AMessage, extractTextFromParts, extractFileParts, extractPushId, extractDeviceType, extractAppVer, extractDisplayVersion, extractModelName, extractTriggerData, extractRunCrossTaskContext } from "./parser.js";
|
|
5
5
|
import { downloadFilesFromParts } from "./file-download.js";
|
|
6
6
|
import { resolveXYConfig } from "./config.js";
|
|
7
7
|
import { sendStatusUpdate, sendClearContextResponse, sendTasksCancelResponse, sendA2AResponse } from "./formatter.js";
|
|
@@ -139,6 +139,15 @@ export async function handleXYMessage(params) {
|
|
|
139
139
|
if (deviceType) {
|
|
140
140
|
log.log(`[BOT] Extracted deviceType: ${deviceType}`);
|
|
141
141
|
}
|
|
142
|
+
// Extract app_ver and display_version if present
|
|
143
|
+
const appVer = extractAppVer(parsed.parts);
|
|
144
|
+
if (appVer) {
|
|
145
|
+
log.log(`[BOT] Extracted app_ver: ${appVer}`);
|
|
146
|
+
}
|
|
147
|
+
const displayVersion = extractDisplayVersion(parsed.parts);
|
|
148
|
+
if (displayVersion) {
|
|
149
|
+
log.log(`[BOT] Extracted display_version: ${displayVersion}`);
|
|
150
|
+
}
|
|
142
151
|
// Extract modelName if present (used by provider.ts to override model.id)
|
|
143
152
|
const modelName = extractModelName(parsed.parts);
|
|
144
153
|
if (modelName) {
|
package/dist/src/channel.js
CHANGED
|
@@ -133,12 +133,17 @@ export const xyPlugin = {
|
|
|
133
133
|
gateway: {
|
|
134
134
|
async startAccount(context) {
|
|
135
135
|
const { monitorXYProvider } = await import("./monitor.js");
|
|
136
|
+
const { createXyAcpBindingManager } = await import("./acp-session-binding.js");
|
|
136
137
|
const account = resolveXYConfig(context.cfg);
|
|
137
138
|
context.setStatus?.({
|
|
138
139
|
accountId: context.accountId,
|
|
139
140
|
wsUrl: account.wsUrl,
|
|
140
141
|
});
|
|
141
142
|
context.log?.info(`[${context.accountId}] starting xiaoyi channel (wsUrl: ${account.wsUrl})`);
|
|
143
|
+
// Register ACP session binding adapter for this account.
|
|
144
|
+
// Enables sessions_spawn(runtime="acp") to bind subagent sessions
|
|
145
|
+
// to the current A2A conversation.
|
|
146
|
+
createXyAcpBindingManager({ accountId: context.accountId, cfg: context.cfg });
|
|
142
147
|
return monitorXYProvider({
|
|
143
148
|
config: context.cfg,
|
|
144
149
|
runtime: context.runtime,
|
|
@@ -5,6 +5,7 @@ import https from 'https';
|
|
|
5
5
|
import { URL } from 'url';
|
|
6
6
|
import { getConfig } from './config.js';
|
|
7
7
|
import { DEFAULT_HTTPS_PORT, HTTP_STATUS_BAD_REQUEST, API_URL_SUFFIX } from './constants.js';
|
|
8
|
+
import { logger } from '../utils/logger.js';
|
|
8
9
|
function buildHeadersForCelia(config, sessionId) {
|
|
9
10
|
if (!config.uid || !config.apiKey || !config.skillId || !config.requestFrom) {
|
|
10
11
|
throw new Error('[SENTINEL HOOK] Missing required configuration: uid, apiKey, skillId, or requestFrom is not defined');
|
|
@@ -89,6 +90,7 @@ export async function callApi(questionText, api, sessionId, action) {
|
|
|
89
90
|
};
|
|
90
91
|
const httpBody = JSON.stringify(payload);
|
|
91
92
|
const apiUrl = `${config.api.url}${API_URL_SUFFIX}`;
|
|
93
|
+
logger.log(`[SENTINEL HOOK] callApi: action=${action}, x-hag-trace-id=${sessionId}, url=${apiUrl}`);
|
|
92
94
|
return new Promise((resolve, reject) => {
|
|
93
95
|
const options = buildRequestOptions(apiUrl, headersForCelia, config.api.timeout);
|
|
94
96
|
const req = https.request(options, (res) => {
|
|
@@ -12,9 +12,10 @@ import { tryInjectSteer } from './steer-context.js';
|
|
|
12
12
|
export default function register(api) {
|
|
13
13
|
api.on("before_tool_call", async (event, ctx) => {
|
|
14
14
|
logger.log(`[SENTINEL HOOK] before_tool_call_event toolName: ${event.toolName}`);
|
|
15
|
-
//
|
|
16
|
-
const
|
|
17
|
-
|
|
15
|
+
// 获取真实sessionID:优先使用ALS中的A2A sessionId,降级到OpenClaw runId或随机值
|
|
16
|
+
const sessionCtx = getCurrentSessionContext();
|
|
17
|
+
const sessionId = sessionCtx?.sessionId || (event.runId?.replace(/-/g, '') || crypto.randomBytes(16).toString('hex'));
|
|
18
|
+
logger.log(`[SENTINEL HOOK] Session ID: ${sessionId} (fromALS: ${!!sessionCtx?.sessionId})`);
|
|
18
19
|
// 处理 TOOL_INPUT 数据采集、发送数据,根据扫描结果决定是否阻塞
|
|
19
20
|
try {
|
|
20
21
|
let scanResult = null;
|
|
@@ -43,9 +44,10 @@ export default function register(api) {
|
|
|
43
44
|
}
|
|
44
45
|
try {
|
|
45
46
|
logger.log(`[SENTINEL HOOK] after_tool_call_event toolName: ${event.toolName}`);
|
|
46
|
-
//
|
|
47
|
-
const
|
|
48
|
-
|
|
47
|
+
// 获取真实sessionID:优先使用ALS中的A2A sessionId,降级到OpenClaw runId或随机值
|
|
48
|
+
const sessionCtx = getCurrentSessionContext();
|
|
49
|
+
const sessionId = sessionCtx?.sessionId || (event.runId?.replace(/-/g, '') || crypto.randomBytes(16).toString('hex'));
|
|
50
|
+
logger.log(`[SENTINEL HOOK] Session ID: ${sessionId} (fromALS: ${!!sessionCtx?.sessionId})`);
|
|
49
51
|
// 处理TOOL_OUTPUT数据采集(保持现有逻辑)
|
|
50
52
|
const resultText = extractResultText(event, event.toolName);
|
|
51
53
|
const resultTextLength = resultText.length;
|
|
@@ -78,7 +80,6 @@ export default function register(api) {
|
|
|
78
80
|
logger.log(`[SENTINEL HOOK] TOOL_OUTPUT response: status=${result.status}.`);
|
|
79
81
|
if (result.status === 'REJECT') {
|
|
80
82
|
logger.warn('[SENTINEL HOOK] REJECT detected, attempting steer injection');
|
|
81
|
-
const sessionCtx = getCurrentSessionContext();
|
|
82
83
|
if (sessionCtx?.sessionId && sessionCtx?.taskId) {
|
|
83
84
|
await tryInjectSteer({
|
|
84
85
|
sessionId: sessionCtx.sessionId,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LogReporterConfig } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Load and validate the JSON config file.
|
|
4
|
+
* Falls back to defaults for optional fields.
|
|
5
|
+
*/
|
|
6
|
+
export declare function loadConfig(configPath: string): LogReporterConfig;
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a path template with date wildcards to actual file paths on disk.
|
|
9
|
+
* Scans the directory and returns all files matching the pattern.
|
|
10
|
+
*/
|
|
11
|
+
export declare function resolveLogFiles(templatePath: string): string[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Config loader: reads JSON config file, validates fields, resolves date wildcards to actual files
|
|
2
|
+
import { readFileSync, readdirSync } from "fs";
|
|
3
|
+
import { dirname, basename, join } from "path";
|
|
4
|
+
// Replace longer tokens first to avoid partial matches
|
|
5
|
+
const WILDCARD_TOKENS = [
|
|
6
|
+
["{year-month-day}", "\\d{4}-\\d{2}-\\d{2}"],
|
|
7
|
+
["{year}{month}{day}", "\\d{8}"],
|
|
8
|
+
["{year}", "\\d{4}"],
|
|
9
|
+
["{month}", "\\d{2}"],
|
|
10
|
+
["{day}", "\\d{2}"],
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* Load and validate the JSON config file.
|
|
14
|
+
* Falls back to defaults for optional fields.
|
|
15
|
+
*/
|
|
16
|
+
export function loadConfig(configPath) {
|
|
17
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
18
|
+
const parsed = JSON.parse(raw);
|
|
19
|
+
if (!parsed.logFiles || !Array.isArray(parsed.logFiles) || parsed.logFiles.length === 0) {
|
|
20
|
+
throw new Error("log-reporter config: 'logFiles' must be a non-empty array");
|
|
21
|
+
}
|
|
22
|
+
for (const lf of parsed.logFiles) {
|
|
23
|
+
if (!lf.path || !lf.name) {
|
|
24
|
+
throw new Error(`log-reporter config: each logFile must have 'path' and 'name', got ${JSON.stringify(lf)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
scanIntervalMs: parsed.scanIntervalMs ?? 600000,
|
|
29
|
+
bakDir: parsed.bakDir ?? "/tmp/openclaw",
|
|
30
|
+
reportUrl: parsed.reportUrl ?? "",
|
|
31
|
+
logFiles: parsed.logFiles,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Convert a path with date wildcards into a RegExp that matches the filename part only.
|
|
36
|
+
* Returns { dir, regex } where dir is the directory portion and regex matches filenames.
|
|
37
|
+
*/
|
|
38
|
+
function pathToPattern(templatePath) {
|
|
39
|
+
const dir = dirname(templatePath);
|
|
40
|
+
let pattern = basename(templatePath);
|
|
41
|
+
// Escape regex special chars in the literal parts, then replace tokens
|
|
42
|
+
pattern = escapeRegex(pattern);
|
|
43
|
+
for (const [token, replacement] of WILDCARD_TOKENS) {
|
|
44
|
+
pattern = pattern.replaceAll(escapeRegex(token), replacement);
|
|
45
|
+
}
|
|
46
|
+
return { dir, regex: new RegExp(`^${pattern}$`) };
|
|
47
|
+
}
|
|
48
|
+
function escapeRegex(s) {
|
|
49
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a path template with date wildcards to actual file paths on disk.
|
|
53
|
+
* Scans the directory and returns all files matching the pattern.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveLogFiles(templatePath) {
|
|
56
|
+
const { dir, regex } = pathToPattern(templatePath);
|
|
57
|
+
let entries;
|
|
58
|
+
try {
|
|
59
|
+
entries = readdirSync(dir);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return entries
|
|
65
|
+
.filter((f) => regex.test(f))
|
|
66
|
+
.map((f) => join(dir, f))
|
|
67
|
+
.sort(); // chronological order for date-named logs
|
|
68
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { CursorStore, FileCursor } from "./types.js";
|
|
2
|
+
export declare function loadCursorStore(storePath: string): CursorStore;
|
|
3
|
+
export declare function saveCursorStore(storePath: string, store: CursorStore): void;
|
|
4
|
+
export declare function getCursor(store: CursorStore, filePath: string): FileCursor | undefined;
|
|
5
|
+
export declare function setCursor(store: CursorStore, filePath: string, cursor: FileCursor): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Cursor state persistence — reads/writes FileCursor entries keyed by file path
|
|
2
|
+
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
export function loadCursorStore(storePath) {
|
|
5
|
+
try {
|
|
6
|
+
const raw = readFileSync(storePath, "utf-8");
|
|
7
|
+
const parsed = JSON.parse(raw);
|
|
8
|
+
return { files: parsed.files ?? {} };
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return { files: {} };
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function saveCursorStore(storePath, store) {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(dirname(storePath), { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
catch { }
|
|
19
|
+
writeFileSync(storePath, JSON.stringify(store, null, 2), "utf-8");
|
|
20
|
+
}
|
|
21
|
+
export function getCursor(store, filePath) {
|
|
22
|
+
return store.files[filePath];
|
|
23
|
+
}
|
|
24
|
+
export function setCursor(store, filePath, cursor) {
|
|
25
|
+
store.files[filePath] = cursor;
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { LogReporterOptions } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Start the log reporter. Runs the first scan immediately, then on the configured interval.
|
|
4
|
+
* Returns a stop function.
|
|
5
|
+
*/
|
|
6
|
+
export declare function startLogReporter(options: LogReporterOptions): Promise<() => void>;
|
|
7
|
+
/**
|
|
8
|
+
* Stop the log reporter timer.
|
|
9
|
+
*/
|
|
10
|
+
export declare function stopLogReporter(): void;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Log Reporter Framework
|
|
2
|
+
// Self-contained periodic log scanner + uploader + reporter.
|
|
3
|
+
// Start via startLogReporter(), stop via stopLogReporter().
|
|
4
|
+
import { resolveLogFiles, loadConfig } from "./config-loader.js";
|
|
5
|
+
import { scanFile } from "./scanner.js";
|
|
6
|
+
import { uploadIncrementalContent } from "./uploader.js";
|
|
7
|
+
import { sendReport } from "./reporter.js";
|
|
8
|
+
import { loadCursorStore, saveCursorStore, setCursor } from "./cursor-store.js";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
let intervalId = null;
|
|
11
|
+
let isRunning = false;
|
|
12
|
+
/**
|
|
13
|
+
* Start the log reporter. Runs the first scan immediately, then on the configured interval.
|
|
14
|
+
* Returns a stop function.
|
|
15
|
+
*/
|
|
16
|
+
export async function startLogReporter(options) {
|
|
17
|
+
const config = loadConfig(options.configPath);
|
|
18
|
+
const cursorPath = join(config.bakDir, ".log-reporter-cursor.json");
|
|
19
|
+
console.log(`[log-reporter] Starting with interval ${config.scanIntervalMs}ms, ${config.logFiles.length} log file(s) configured`);
|
|
20
|
+
async function doScan() {
|
|
21
|
+
if (isRunning)
|
|
22
|
+
return; // skip if previous scan still running
|
|
23
|
+
isRunning = true;
|
|
24
|
+
try {
|
|
25
|
+
const cursorStore = loadCursorStore(cursorPath);
|
|
26
|
+
for (const logFile of config.logFiles) {
|
|
27
|
+
const resolvedFiles = resolveLogFiles(logFile.path);
|
|
28
|
+
console.log(`[log-reporter] Scanning "${logFile.name}": pattern=${logFile.path}, resolved ${resolvedFiles.length} file(s)`);
|
|
29
|
+
for (const filePath of resolvedFiles) {
|
|
30
|
+
await processFile(filePath, logFile.name, config, cursorStore, options);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
saveCursorStore(cursorPath, cursorStore);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error("[log-reporter] Scan failed:", err);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
isRunning = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Run first scan immediately
|
|
43
|
+
await doScan();
|
|
44
|
+
// Schedule periodic scans
|
|
45
|
+
intervalId = setInterval(doScan, config.scanIntervalMs);
|
|
46
|
+
intervalId.unref?.();
|
|
47
|
+
return () => stopLogReporter();
|
|
48
|
+
}
|
|
49
|
+
async function processFile(filePath, name, config, cursorStore, options) {
|
|
50
|
+
try {
|
|
51
|
+
const result = await scanFile(filePath, name, cursorStore);
|
|
52
|
+
if (!result)
|
|
53
|
+
return;
|
|
54
|
+
console.log(`[log-reporter] New content in "${name}": ${filePath} lines ${result.lineStart}-${result.lineEnd} (${result.newLineCount} lines)`);
|
|
55
|
+
// Upload .bak → get URL
|
|
56
|
+
const url = await uploadIncrementalContent(result, config.bakDir, options.uploadService);
|
|
57
|
+
console.log(`[log-reporter] Uploaded .bak for "${name}", url: ${url}`);
|
|
58
|
+
// Send report (mock)
|
|
59
|
+
await sendReport(config.reportUrl, url, result);
|
|
60
|
+
// Only persist cursor after successful upload + report
|
|
61
|
+
setCursor(cursorStore, filePath, result.newCursor);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
console.error(`[log-reporter] Failed processing "${name}" (${filePath}):`, err);
|
|
65
|
+
// Cursor NOT updated — will retry on next scan
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Stop the log reporter timer.
|
|
70
|
+
*/
|
|
71
|
+
export function stopLogReporter() {
|
|
72
|
+
if (intervalId !== null) {
|
|
73
|
+
clearInterval(intervalId);
|
|
74
|
+
intervalId = null;
|
|
75
|
+
console.log("[log-reporter] Stopped");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ScanResult } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Send a log report to the server with the uploaded file URL.
|
|
4
|
+
* MOCK implementation — real request logic will be added later.
|
|
5
|
+
*/
|
|
6
|
+
export declare function sendReport(reportUrl: string, fileUrl: string, result: ScanResult): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send a log report to the server with the uploaded file URL.
|
|
3
|
+
* MOCK implementation — real request logic will be added later.
|
|
4
|
+
*/
|
|
5
|
+
export async function sendReport(reportUrl, fileUrl, result) {
|
|
6
|
+
// TODO: Replace with actual HTTP request
|
|
7
|
+
const payload = {
|
|
8
|
+
logName: result.name,
|
|
9
|
+
filePath: result.filePath,
|
|
10
|
+
lineStart: result.lineStart,
|
|
11
|
+
lineEnd: result.lineEnd,
|
|
12
|
+
newLineCount: result.newLineCount,
|
|
13
|
+
fileUrl,
|
|
14
|
+
timestamp: new Date().toISOString(),
|
|
15
|
+
};
|
|
16
|
+
console.log(`[log-reporter] Mock report to ${reportUrl}:`, JSON.stringify(payload, null, 2));
|
|
17
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ScanResult, CursorStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scan a single log file for new content.
|
|
4
|
+
* Returns a ScanResult if there are new lines, or null if no changes.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scanFile(filePath: string, name: string, cursorStore: CursorStore): Promise<ScanResult | null>;
|