@cernion/openclaw-energy-tools-sidecar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +31 -0
- package/LICENSE +201 -0
- package/README.md +202 -0
- package/SECURITY.md +27 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +1053 -0
- package/docker/.env.example +40 -0
- package/docker/Dockerfile +36 -0
- package/docker/README.md +302 -0
- package/docker/compose.sidecar-it.yml +49 -0
- package/docker/compose.yml +29 -0
- package/docker/entrypoint.sh +249 -0
- package/docker/profiles/cernion-demo/AGENTS.md +45 -0
- package/docker/profiles/cernion-demo/SOUL.md +23 -0
- package/docker/profiles/cernion-demo/TOOLS.md +111 -0
- package/openclaw.plugin.json +136 -0
- package/package.json +52 -0
- package/scripts/sidecar-smoke.mjs +127 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineToolPlugin } from "openclaw/plugin-sdk/tool-plugin";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
5
|
+
const DEFAULT_ASSET_LIST_LIMIT = 500;
|
|
6
|
+
const configSchema = Type.Object({
|
|
7
|
+
baseUrl: Type.Optional(Type.String({ description: "Cernion base URL, for example https://cernion.example" })),
|
|
8
|
+
bearerToken: Type.Optional(Type.String({ description: "Read-only Cernion bearer token. Prefer the OpenClaw secret store." })),
|
|
9
|
+
bearerTokenEnv: Type.Optional(Type.String({ description: "Environment variable name that contains the bearer token." })),
|
|
10
|
+
bearerTokenFile: Type.Optional(Type.String({ description: "Path to a local 0600 file containing the read-only bearer token." })),
|
|
11
|
+
processBearerToken: Type.Optional(Type.String({ description: "Cernion process-intake bearer token. Keep separate from the read-only token." })),
|
|
12
|
+
processBearerTokenEnv: Type.Optional(Type.String({ description: "Environment variable name that contains the process-intake bearer token." })),
|
|
13
|
+
processBearerTokenFile: Type.Optional(Type.String({ description: "Path to a local 0600 file containing the process-intake bearer token." })),
|
|
14
|
+
allowRestProxy: Type.Optional(Type.Boolean({ description: "Allow read-only REST execution plans emitted by Cernion to be proxied through this sidecar." })),
|
|
15
|
+
timeoutMs: Type.Optional(Type.Number({ description: "HTTP request timeout in milliseconds." })),
|
|
16
|
+
}, { additionalProperties: false });
|
|
17
|
+
function stripTrailingSlash(value) {
|
|
18
|
+
return value.replace(/\/+$/, "");
|
|
19
|
+
}
|
|
20
|
+
function readBearerTokenFile(path) {
|
|
21
|
+
const raw = readFileSync(path, "utf8").trim();
|
|
22
|
+
const envMatch = raw.match(/^[A-Z0-9_]*TOKEN=(.*)$/m);
|
|
23
|
+
return (envMatch ? envMatch[1] : raw).trim().replace(/^['"]|['"]$/g, "");
|
|
24
|
+
}
|
|
25
|
+
function resolveReadOnlyBearerToken(config) {
|
|
26
|
+
const tokenEnv = (config.bearerTokenEnv || "CERNION_READONLY_TOKEN").trim();
|
|
27
|
+
const tokenFile = (config.bearerTokenFile || process.env.CERNION_READONLY_TOKEN_FILE || "").trim();
|
|
28
|
+
return (config.bearerToken ||
|
|
29
|
+
process.env[tokenEnv] ||
|
|
30
|
+
process.env.CERNION_SIDECAR_TOKEN ||
|
|
31
|
+
(tokenFile ? readBearerTokenFile(tokenFile) : ""))
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(/^Bearer\s+/i, "");
|
|
34
|
+
}
|
|
35
|
+
function requireConfig(config) {
|
|
36
|
+
const baseUrl = (config.baseUrl || process.env.CERNION_BASE_URL || "").trim();
|
|
37
|
+
const bearerToken = resolveReadOnlyBearerToken(config);
|
|
38
|
+
if (!baseUrl) {
|
|
39
|
+
throw new Error("Cernion baseUrl is required. Set plugin config baseUrl or CERNION_BASE_URL.");
|
|
40
|
+
}
|
|
41
|
+
if (!bearerToken) {
|
|
42
|
+
throw new Error("Cernion read-only bearer token is required. Set plugin secret bearerToken or CERNION_READONLY_TOKEN.");
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
baseUrl: stripTrailingSlash(baseUrl),
|
|
46
|
+
bearerToken,
|
|
47
|
+
timeoutMs: Math.max(1000, Number(config.timeoutMs || process.env.CERNION_SIDECAR_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function requireProcessConfig(config) {
|
|
51
|
+
const baseUrl = (config.baseUrl || process.env.CERNION_BASE_URL || "").trim();
|
|
52
|
+
const tokenEnv = (config.processBearerTokenEnv || "CERNION_PROCESS_TOKEN").trim();
|
|
53
|
+
const tokenFile = (config.processBearerTokenFile || process.env.CERNION_PROCESS_TOKEN_FILE || "").trim();
|
|
54
|
+
const bearerToken = (config.processBearerToken ||
|
|
55
|
+
process.env[tokenEnv] ||
|
|
56
|
+
(tokenFile ? readBearerTokenFile(tokenFile) : ""))
|
|
57
|
+
.trim()
|
|
58
|
+
.replace(/^Bearer\s+/i, "");
|
|
59
|
+
if (!baseUrl) {
|
|
60
|
+
throw new Error("Cernion baseUrl is required. Set plugin config baseUrl or CERNION_BASE_URL.");
|
|
61
|
+
}
|
|
62
|
+
if (!bearerToken) {
|
|
63
|
+
throw new Error("Cernion process-intake bearer token is required. Set processBearerToken, processBearerTokenFile, or CERNION_PROCESS_TOKEN.");
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
baseUrl: stripTrailingSlash(baseUrl),
|
|
67
|
+
bearerToken,
|
|
68
|
+
timeoutMs: Math.max(1000, Number(config.timeoutMs || process.env.CERNION_SIDECAR_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function buildUrl(baseUrl, path) {
|
|
72
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
73
|
+
return `${baseUrl}${normalizedPath}`;
|
|
74
|
+
}
|
|
75
|
+
function buildQueryPath(path, params = {}) {
|
|
76
|
+
const queryParams = new URLSearchParams();
|
|
77
|
+
for (const [key, val] of Object.entries(params)) {
|
|
78
|
+
if (val !== undefined && val !== null && val !== "") {
|
|
79
|
+
queryParams.append(key, String(val));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!queryParams.toString())
|
|
83
|
+
return path;
|
|
84
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
85
|
+
return `${path}${separator}${queryParams.toString()}`;
|
|
86
|
+
}
|
|
87
|
+
function parseQueryPath(path) {
|
|
88
|
+
const [pathname, rawQuery = ""] = path.split("?", 2);
|
|
89
|
+
const params = {};
|
|
90
|
+
const searchParams = new URLSearchParams(rawQuery);
|
|
91
|
+
for (const [key, value] of searchParams.entries()) {
|
|
92
|
+
params[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return { pathname, params };
|
|
95
|
+
}
|
|
96
|
+
function isAssetListPath(path) {
|
|
97
|
+
const { pathname } = parseQueryPath(path);
|
|
98
|
+
const normalizedPath = pathname.toLowerCase();
|
|
99
|
+
return normalizedPath === "/api/assets" || normalizedPath.startsWith("/api/assets/");
|
|
100
|
+
}
|
|
101
|
+
function normalizeReadOnlyGetParams(path, params = {}) {
|
|
102
|
+
const mergedParams = { ...parseQueryPath(path).params, ...params };
|
|
103
|
+
if (!isAssetListPath(path))
|
|
104
|
+
return mergedParams;
|
|
105
|
+
if (mergedParams.limit !== undefined && mergedParams.limit !== null && mergedParams.limit !== "")
|
|
106
|
+
return mergedParams;
|
|
107
|
+
return {
|
|
108
|
+
...mergedParams,
|
|
109
|
+
limit: DEFAULT_ASSET_LIST_LIMIT,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function buildReadOnlyGetPath(path, params = {}) {
|
|
113
|
+
const { pathname } = parseQueryPath(path);
|
|
114
|
+
return buildQueryPath(pathname, normalizeReadOnlyGetParams(path, params));
|
|
115
|
+
}
|
|
116
|
+
function combineAbortSignals(signals) {
|
|
117
|
+
const activeSignals = signals.filter(Boolean);
|
|
118
|
+
if (activeSignals.length === 1)
|
|
119
|
+
return activeSignals[0];
|
|
120
|
+
const alreadyAborted = activeSignals.find((signal) => signal.aborted);
|
|
121
|
+
if (alreadyAborted)
|
|
122
|
+
return alreadyAborted;
|
|
123
|
+
const controller = new AbortController();
|
|
124
|
+
const abort = () => controller.abort();
|
|
125
|
+
for (const signal of activeSignals) {
|
|
126
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
127
|
+
}
|
|
128
|
+
return controller.signal;
|
|
129
|
+
}
|
|
130
|
+
function isRestProxyAllowed(config) {
|
|
131
|
+
if (config.allowRestProxy !== undefined)
|
|
132
|
+
return config.allowRestProxy;
|
|
133
|
+
const value = (process.env.CERNION_ALLOW_REST_PROXY || "").trim().toLowerCase();
|
|
134
|
+
if (!value)
|
|
135
|
+
return true;
|
|
136
|
+
return !["0", "false", "no", "off"].includes(value);
|
|
137
|
+
}
|
|
138
|
+
function validateRestExecutionPlan(plan) {
|
|
139
|
+
const method = (plan.method || "GET").toUpperCase();
|
|
140
|
+
if (method !== "GET") {
|
|
141
|
+
throw new Error("Only read-only GET execution plans can be proxied by the Cernion Sidecar.");
|
|
142
|
+
}
|
|
143
|
+
if (!plan.path || typeof plan.path !== "string") {
|
|
144
|
+
throw new Error("REST execution plan requires a relative Cernion API path.");
|
|
145
|
+
}
|
|
146
|
+
if (plan.path.includes("://") || plan.path.startsWith("//") || !plan.path.startsWith("/api/")) {
|
|
147
|
+
throw new Error("REST execution plan path must be a relative /api/ path.");
|
|
148
|
+
}
|
|
149
|
+
const lowerPath = plan.path.toLowerCase();
|
|
150
|
+
const blockedPathMarkers = [
|
|
151
|
+
"/api/admin",
|
|
152
|
+
"/api/auth",
|
|
153
|
+
"/api/token",
|
|
154
|
+
"/api/tokens",
|
|
155
|
+
"/api/secret",
|
|
156
|
+
"/api/secrets",
|
|
157
|
+
"/api/hitl/resolve",
|
|
158
|
+
"/api/agent-sidecar/mcp/tools/",
|
|
159
|
+
];
|
|
160
|
+
if (blockedPathMarkers.some((marker) => lowerPath.startsWith(marker))) {
|
|
161
|
+
throw new Error("REST execution plan path is outside the read-only Sidecar proxy boundary.");
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
method: "GET",
|
|
165
|
+
path: plan.path,
|
|
166
|
+
params: { ...(plan.params || {}), ...(plan.query || {}) },
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function assertRelativeApiPath(path) {
|
|
170
|
+
if (!path || typeof path !== "string") {
|
|
171
|
+
throw new Error("Cernion API plan requires a relative Cernion API path.");
|
|
172
|
+
}
|
|
173
|
+
if (path.includes("://") || path.startsWith("//") || !path.startsWith("/api/")) {
|
|
174
|
+
throw new Error("Cernion API plan path must be a relative /api/ path.");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function assertNotBlockedPath(path) {
|
|
178
|
+
const lowerPath = path.toLowerCase();
|
|
179
|
+
const blockedPathMarkers = [
|
|
180
|
+
"/api/admin",
|
|
181
|
+
"/api/auth",
|
|
182
|
+
"/api/token",
|
|
183
|
+
"/api/tokens",
|
|
184
|
+
"/api/secret",
|
|
185
|
+
"/api/secrets",
|
|
186
|
+
"/api/hitl/resolve",
|
|
187
|
+
"/api/agent-sidecar/mcp/tools/",
|
|
188
|
+
];
|
|
189
|
+
if (blockedPathMarkers.some((marker) => lowerPath.startsWith(marker))) {
|
|
190
|
+
throw new Error("Cernion API plan path is outside the Sidecar proxy boundary.");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function validateEvidenceEndpointPlan(plan) {
|
|
194
|
+
const method = (plan.method || "GET").toUpperCase();
|
|
195
|
+
if (method !== "GET" && method !== "POST") {
|
|
196
|
+
throw new Error("Only read-only GET or POST evidence endpoint plans can be proxied by the Cernion Sidecar.");
|
|
197
|
+
}
|
|
198
|
+
assertRelativeApiPath(plan.path);
|
|
199
|
+
assertNotBlockedPath(plan.path);
|
|
200
|
+
const lowerPath = plan.path.toLowerCase();
|
|
201
|
+
if (lowerPath.startsWith("/api/copilot-process")) {
|
|
202
|
+
throw new Error("Process Intake paths must not be executed through the Evidence Endpoint proxy.");
|
|
203
|
+
}
|
|
204
|
+
if (plan.policy?.readOnly !== true) {
|
|
205
|
+
throw new Error("Evidence endpoint plan requires policy.readOnly=true from Cernion.");
|
|
206
|
+
}
|
|
207
|
+
if (plan.policy?.sideEffects !== undefined && plan.policy.sideEffects !== "none") {
|
|
208
|
+
throw new Error("Evidence endpoint plan requires sideEffects=none.");
|
|
209
|
+
}
|
|
210
|
+
const params = { ...(plan.params || {}), ...(plan.query || {}) };
|
|
211
|
+
return {
|
|
212
|
+
method: method,
|
|
213
|
+
path: plan.path,
|
|
214
|
+
params,
|
|
215
|
+
body: plan.body || params,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function executeRestExecutionPlan(config, plan, signal) {
|
|
219
|
+
if (!isRestProxyAllowed(config)) {
|
|
220
|
+
throw new Error("Cernion read-only REST proxy is disabled by configuration.");
|
|
221
|
+
}
|
|
222
|
+
const validated = validateRestExecutionPlan(plan);
|
|
223
|
+
const params = normalizeReadOnlyGetParams(validated.path, validated.params);
|
|
224
|
+
const result = await requestCernion(config, buildReadOnlyGetPath(validated.path, validated.params), {
|
|
225
|
+
method: validated.method,
|
|
226
|
+
signal,
|
|
227
|
+
});
|
|
228
|
+
return annotateAssetListResult(result, validated.path, params);
|
|
229
|
+
}
|
|
230
|
+
async function routeEvidence(config, request, signal) {
|
|
231
|
+
return requestCernion(config, "/api/evidence-router/route", {
|
|
232
|
+
method: "POST",
|
|
233
|
+
body: {
|
|
234
|
+
question: request.question,
|
|
235
|
+
context: request.context || {},
|
|
236
|
+
},
|
|
237
|
+
signal,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function normalizeDomainKnowledgeQuery(request) {
|
|
241
|
+
const queryType = request.queryType || "semantic";
|
|
242
|
+
if (!["semantic", "scroll", "fetch", "collection_info"].includes(queryType)) {
|
|
243
|
+
throw new Error("queryType must be one of semantic, scroll, fetch, collection_info.");
|
|
244
|
+
}
|
|
245
|
+
if (queryType === "semantic" && !String(request.query || "").trim()) {
|
|
246
|
+
throw new Error("query is required for semantic domain knowledge lookup.");
|
|
247
|
+
}
|
|
248
|
+
if (queryType === "fetch" && (!Array.isArray(request.ids) || request.ids.length === 0)) {
|
|
249
|
+
throw new Error("ids is required for fetch domain knowledge lookup.");
|
|
250
|
+
}
|
|
251
|
+
const limit = request.limit === undefined || request.limit === null
|
|
252
|
+
? 10
|
|
253
|
+
: Math.max(1, Math.min(100, Number(request.limit)));
|
|
254
|
+
return {
|
|
255
|
+
queryType,
|
|
256
|
+
...(request.query === undefined ? {} : { query: request.query }),
|
|
257
|
+
limit,
|
|
258
|
+
...(request.scoreThreshold === undefined ? {} : { scoreThreshold: request.scoreThreshold }),
|
|
259
|
+
...(request.ids === undefined ? {} : { ids: request.ids }),
|
|
260
|
+
...(request.offset === undefined ? {} : { offset: request.offset }),
|
|
261
|
+
...(request.filter === undefined ? {} : { filter: request.filter }),
|
|
262
|
+
withPayload: request.withPayload === undefined ? true : request.withPayload,
|
|
263
|
+
withVectors: request.withVectors === true,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function isObject(value) {
|
|
267
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
268
|
+
}
|
|
269
|
+
function toPositiveInteger(value) {
|
|
270
|
+
const numberValue = Number(value);
|
|
271
|
+
if (!Number.isFinite(numberValue) || numberValue <= 0)
|
|
272
|
+
return undefined;
|
|
273
|
+
return Math.floor(numberValue);
|
|
274
|
+
}
|
|
275
|
+
function countListItems(result) {
|
|
276
|
+
const candidates = [
|
|
277
|
+
result.assets,
|
|
278
|
+
result.items,
|
|
279
|
+
result.results,
|
|
280
|
+
result.installations,
|
|
281
|
+
result.data,
|
|
282
|
+
getNestedObject(result, ["data"]).assets,
|
|
283
|
+
getNestedObject(result, ["data"]).items,
|
|
284
|
+
getNestedObject(result, ["data"]).results,
|
|
285
|
+
getNestedObject(result, ["structuredContent"]).assets,
|
|
286
|
+
getNestedObject(result, ["structuredContent"]).items,
|
|
287
|
+
getNestedObject(result, ["structuredContent"]).results,
|
|
288
|
+
];
|
|
289
|
+
const list = candidates.find(Array.isArray);
|
|
290
|
+
return Array.isArray(list) ? list.length : undefined;
|
|
291
|
+
}
|
|
292
|
+
function extractTotalCount(result) {
|
|
293
|
+
const candidates = [
|
|
294
|
+
result.total,
|
|
295
|
+
result.totalCount,
|
|
296
|
+
result.totalResults,
|
|
297
|
+
result.totalMatchingAssetsCount,
|
|
298
|
+
result.countTotal,
|
|
299
|
+
getNestedObject(result, ["data"]).total,
|
|
300
|
+
getNestedObject(result, ["data"]).totalCount,
|
|
301
|
+
getNestedObject(result, ["data"]).totalMatchingAssetsCount,
|
|
302
|
+
getNestedObject(result, ["structuredContent"]).total,
|
|
303
|
+
getNestedObject(result, ["structuredContent"]).totalCount,
|
|
304
|
+
getNestedObject(result, ["structuredContent"]).totalMatchingAssetsCount,
|
|
305
|
+
];
|
|
306
|
+
return candidates.map(toPositiveInteger).find((value) => value !== undefined);
|
|
307
|
+
}
|
|
308
|
+
function annotateAssetListResult(result, path, params) {
|
|
309
|
+
if (!isAssetListPath(path) || !isObject(result) || result.isError === true)
|
|
310
|
+
return result;
|
|
311
|
+
const { pathname } = parseQueryPath(path);
|
|
312
|
+
const requestedLimit = toPositiveInteger(params.limit);
|
|
313
|
+
if (!requestedLimit)
|
|
314
|
+
return result;
|
|
315
|
+
const returnedCount = countListItems(result);
|
|
316
|
+
const totalCount = extractTotalCount(result);
|
|
317
|
+
const limitExhausted = returnedCount !== undefined && returnedCount >= requestedLimit;
|
|
318
|
+
const hasMore = totalCount !== undefined ? totalCount > (returnedCount || 0) : limitExhausted;
|
|
319
|
+
if (!limitExhausted && !hasMore)
|
|
320
|
+
return result;
|
|
321
|
+
const nextOffset = toPositiveInteger(params.offset) || 0;
|
|
322
|
+
const nextPageParams = {
|
|
323
|
+
...params,
|
|
324
|
+
offset: nextOffset + requestedLimit,
|
|
325
|
+
limit: requestedLimit,
|
|
326
|
+
};
|
|
327
|
+
const exportBaseParams = { ...params, limit: totalCount || requestedLimit };
|
|
328
|
+
return {
|
|
329
|
+
...result,
|
|
330
|
+
_sidecar: {
|
|
331
|
+
assetListPagination: {
|
|
332
|
+
requestedLimit,
|
|
333
|
+
returnedCount,
|
|
334
|
+
...(totalCount === undefined ? {} : { totalCount }),
|
|
335
|
+
limitExhausted,
|
|
336
|
+
hasMore,
|
|
337
|
+
nextPage: {
|
|
338
|
+
path: pathname,
|
|
339
|
+
params: nextPageParams,
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
exportOptions: [
|
|
343
|
+
{
|
|
344
|
+
format: "csv",
|
|
345
|
+
path: pathname,
|
|
346
|
+
params: { ...exportBaseParams, format: "csv" },
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
format: "xls",
|
|
350
|
+
path: pathname,
|
|
351
|
+
params: { ...exportBaseParams, format: "xls" },
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
answerGuidance: "Do not present the current rows as a complete asset inventory when limitExhausted=true or hasMore=true. Tell the user how many rows were returned, mention the requested limit, and offer CSV/XLS export or the next page.",
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
function getNestedObject(value, path) {
|
|
359
|
+
let current = value;
|
|
360
|
+
for (const segment of path) {
|
|
361
|
+
if (!isObject(current))
|
|
362
|
+
return {};
|
|
363
|
+
current = current[segment];
|
|
364
|
+
}
|
|
365
|
+
return isObject(current) ? current : {};
|
|
366
|
+
}
|
|
367
|
+
function getNestedString(value, path) {
|
|
368
|
+
let current = value;
|
|
369
|
+
for (const segment of path) {
|
|
370
|
+
if (!isObject(current))
|
|
371
|
+
return "";
|
|
372
|
+
current = current[segment];
|
|
373
|
+
}
|
|
374
|
+
return typeof current === "string" ? current : "";
|
|
375
|
+
}
|
|
376
|
+
function collectDomainKnowledgeResults(result) {
|
|
377
|
+
if (!isObject(result))
|
|
378
|
+
return [];
|
|
379
|
+
const directResults = Array.isArray(result.results) ? result.results : undefined;
|
|
380
|
+
const dataResults = isObject(result.data) && Array.isArray(result.data.results) ? result.data.results : undefined;
|
|
381
|
+
return (dataResults || directResults || []).filter(isObject);
|
|
382
|
+
}
|
|
383
|
+
function normalizedText(value) {
|
|
384
|
+
if (value === undefined || value === null)
|
|
385
|
+
return "";
|
|
386
|
+
if (typeof value === "string")
|
|
387
|
+
return value.toLowerCase();
|
|
388
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
389
|
+
return String(value).toLowerCase();
|
|
390
|
+
try {
|
|
391
|
+
return JSON.stringify(value).toLowerCase();
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return "";
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function queryMarkers(query) {
|
|
398
|
+
const text = normalizedText(query);
|
|
399
|
+
const explicitRefs = Array.from(text.matchAll(/§\s*\d+[a-z]?|\b\d+[a-z]?\s*enwg\b|\benwg\b|\bbnetza\b/g)).map((match) => match[0].replace(/\s+/g, " ").trim());
|
|
400
|
+
const words = text
|
|
401
|
+
.replace(/[^\p{L}\p{N}§]+/gu, " ")
|
|
402
|
+
.split(/\s+/)
|
|
403
|
+
.map((word) => word.trim())
|
|
404
|
+
.filter((word) => word.length >= 4 && !["welche", "ergeben", "sich", "aus", "eine", "einer", "einem"].includes(word));
|
|
405
|
+
return Array.from(new Set([...explicitRefs, ...words])).slice(0, 12);
|
|
406
|
+
}
|
|
407
|
+
function isRoutingCard(hit) {
|
|
408
|
+
const metadata = getNestedObject(hit, ["metadata"]);
|
|
409
|
+
const payloadMetadata = getNestedObject(hit, ["payload", "metadata"]);
|
|
410
|
+
const source = String(payloadMetadata.source || metadata.source || "").toLowerCase();
|
|
411
|
+
const kind = String(payloadMetadata.kind || metadata.kind || hit.type || "").toLowerCase();
|
|
412
|
+
const documentId = String(getNestedString(hit, ["payload", "documentId"]) || hit.documentId || "").toLowerCase();
|
|
413
|
+
return (source === "manual_strategy_cards" ||
|
|
414
|
+
kind === "strategy_pattern_card" ||
|
|
415
|
+
kind.includes("synonym") ||
|
|
416
|
+
documentId.startsWith("strategy:"));
|
|
417
|
+
}
|
|
418
|
+
function hasSourceMetadata(hit) {
|
|
419
|
+
const metadata = getNestedObject(hit, ["metadata"]);
|
|
420
|
+
const payloadMetadata = getNestedObject(hit, ["payload", "metadata"]);
|
|
421
|
+
return Boolean(metadata.title ||
|
|
422
|
+
metadata.authority ||
|
|
423
|
+
metadata.docType ||
|
|
424
|
+
metadata.status ||
|
|
425
|
+
payloadMetadata.sourceUrl ||
|
|
426
|
+
payloadMetadata.title ||
|
|
427
|
+
payloadMetadata.authority ||
|
|
428
|
+
payloadMetadata.docType ||
|
|
429
|
+
payloadMetadata.status);
|
|
430
|
+
}
|
|
431
|
+
function matchesQuery(hit, markers) {
|
|
432
|
+
if (markers.length === 0)
|
|
433
|
+
return true;
|
|
434
|
+
const text = normalizedText({
|
|
435
|
+
referenceText_L0: hit.referenceText_L0,
|
|
436
|
+
vectorText: hit.vectorText,
|
|
437
|
+
metadata: hit.metadata,
|
|
438
|
+
oeoTags: hit.oeoTags,
|
|
439
|
+
payload: hit.payload,
|
|
440
|
+
});
|
|
441
|
+
return markers.some((marker) => text.includes(marker));
|
|
442
|
+
}
|
|
443
|
+
function assessDomainKnowledgeEvidence(query, result) {
|
|
444
|
+
const hits = collectDomainKnowledgeResults(result);
|
|
445
|
+
const markers = queryMarkers(query);
|
|
446
|
+
let strongEvidenceCount = 0;
|
|
447
|
+
let routingCardCount = 0;
|
|
448
|
+
let weakOrOffTopicCount = 0;
|
|
449
|
+
let topScore;
|
|
450
|
+
for (const hit of hits) {
|
|
451
|
+
const score = typeof hit.score === "number" ? hit.score : undefined;
|
|
452
|
+
if (topScore === undefined && score !== undefined)
|
|
453
|
+
topScore = score;
|
|
454
|
+
const routingCard = isRoutingCard(hit);
|
|
455
|
+
if (routingCard) {
|
|
456
|
+
routingCardCount += 1;
|
|
457
|
+
}
|
|
458
|
+
const strong = !routingCard &&
|
|
459
|
+
hasSourceMetadata(hit) &&
|
|
460
|
+
matchesQuery(hit, markers) &&
|
|
461
|
+
(score === undefined || score >= 0.65);
|
|
462
|
+
if (strong) {
|
|
463
|
+
strongEvidenceCount += 1;
|
|
464
|
+
}
|
|
465
|
+
else if (!routingCard) {
|
|
466
|
+
weakOrOffTopicCount += 1;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const reasons = [];
|
|
470
|
+
if (hits.length === 0)
|
|
471
|
+
reasons.push("Knowledge RAG returned no result chunks.");
|
|
472
|
+
if (routingCardCount > 0) {
|
|
473
|
+
reasons.push("One or more hits are Cernion routing/synonym strategy cards, not primary-source support for hard obligations.");
|
|
474
|
+
}
|
|
475
|
+
if (weakOrOffTopicCount > 0) {
|
|
476
|
+
reasons.push("One or more hits lack source metadata, query match, or semantic score for primary-source support.");
|
|
477
|
+
}
|
|
478
|
+
if (strongEvidenceCount === 0) {
|
|
479
|
+
reasons.push("No strong primary/source-backed evidence chunk was found for a hard legal or procedural claim.");
|
|
480
|
+
}
|
|
481
|
+
let evidenceAdequacy = "low";
|
|
482
|
+
if (strongEvidenceCount >= 2)
|
|
483
|
+
evidenceAdequacy = "high";
|
|
484
|
+
else if (strongEvidenceCount === 1)
|
|
485
|
+
evidenceAdequacy = "medium";
|
|
486
|
+
const answerGuidance = evidenceAdequacy === "high"
|
|
487
|
+
? "The assistant may synthesize a fachliche answer, but should cite or name the Cernion evidence used and keep operational status separate."
|
|
488
|
+
: evidenceAdequacy === "medium"
|
|
489
|
+
? "The assistant may answer only the points directly supported by the sourced Cernion evidence and should state remaining evidence gaps."
|
|
490
|
+
: "The assistant must not present a legal or procedural answer as fully evidenced by Cernion primary sources. State that Cernion returned useful domain or strategy knowledge, but primary-source support for hard obligations is insufficient. Use routing-card content as orientation and avoid filling gaps from model memory or web search unless explicitly requested.";
|
|
491
|
+
return {
|
|
492
|
+
assessmentScope: "primary_source_support",
|
|
493
|
+
evidenceAdequacy,
|
|
494
|
+
strongEvidenceCount,
|
|
495
|
+
routingCardCount,
|
|
496
|
+
weakOrOffTopicCount,
|
|
497
|
+
...(topScore === undefined ? {} : { topScore }),
|
|
498
|
+
reasons,
|
|
499
|
+
answerGuidance,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
function normalizeGridContextQuery(request) {
|
|
503
|
+
const location = String(request.location || "").trim();
|
|
504
|
+
const gridOperator = String(request.gridOperator || "").trim();
|
|
505
|
+
const gridOperatorId = String(request.gridOperatorId || "").trim();
|
|
506
|
+
const boundingBox = isObject(request.boundingBox) ? request.boundingBox : undefined;
|
|
507
|
+
if (!location && !boundingBox && !gridOperator) {
|
|
508
|
+
throw new Error("location, boundingBox, or gridOperator is required for OSM grid context lookup.");
|
|
509
|
+
}
|
|
510
|
+
const voltageLevel = request.voltageLevel;
|
|
511
|
+
if (voltageLevel !== undefined && !["NS", "MS", "HS", "EHS"].includes(voltageLevel)) {
|
|
512
|
+
throw new Error("voltageLevel must be one of NS, MS, HS, EHS.");
|
|
513
|
+
}
|
|
514
|
+
const includeSubstations = request.includeSubstations !== false;
|
|
515
|
+
const includeTopology = request.includeTopology === true;
|
|
516
|
+
if (!includeSubstations && !includeTopology) {
|
|
517
|
+
throw new Error("At least one of includeSubstations or includeTopology must be true.");
|
|
518
|
+
}
|
|
519
|
+
return {
|
|
520
|
+
...(location ? { location } : {}),
|
|
521
|
+
...(boundingBox ? { boundingBox } : {}),
|
|
522
|
+
...(gridOperator ? { gridOperator } : {}),
|
|
523
|
+
...(gridOperatorId ? { gridOperatorId } : {}),
|
|
524
|
+
...(voltageLevel ? { voltageLevel } : {}),
|
|
525
|
+
includeSubstations,
|
|
526
|
+
includeTopology,
|
|
527
|
+
includeGeometry: request.includeGeometry === true,
|
|
528
|
+
includeGraphData: request.includeGraphData === true,
|
|
529
|
+
maxResults: request.maxResults === undefined || request.maxResults === null
|
|
530
|
+
? 200
|
|
531
|
+
: Math.max(1, Math.min(1000, Number(request.maxResults))),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function assessGridContextEvidence(substations, topology) {
|
|
535
|
+
const substationData = getNestedObject(substations, ["data"]);
|
|
536
|
+
const topologyData = getNestedObject(topology, ["data"]);
|
|
537
|
+
const substationSummary = getNestedObject(substationData, ["summary"]);
|
|
538
|
+
const topologyMetrics = getNestedObject(topologyData, ["topologyMetrics"]);
|
|
539
|
+
const topologyQuality = getNestedObject(topologyData, ["dataQuality"]);
|
|
540
|
+
const substationQuality = getNestedObject(substationData, ["dataQuality"]);
|
|
541
|
+
const totalSubstations = Number(substationSummary.totalSubstations || 0);
|
|
542
|
+
const nodes = Number(topologyMetrics.nodes || 0);
|
|
543
|
+
const edges = Number(topologyMetrics.edges || 0);
|
|
544
|
+
const coverageLabel = String(topologyQuality.coverageLabel || substationQuality.coverageLabel || "UNKNOWN");
|
|
545
|
+
let hypothesisStrength = "low";
|
|
546
|
+
if ((totalSubstations > 0 && nodes > 0) || edges > 0)
|
|
547
|
+
hypothesisStrength = "medium";
|
|
548
|
+
if (totalSubstations >= 5 && edges >= 5 && ["MEDIUM", "HIGH"].includes(coverageLabel))
|
|
549
|
+
hypothesisStrength = "medium";
|
|
550
|
+
const warnings = [
|
|
551
|
+
"OSM grid context is public visible-infrastructure evidence and must be treated as a planning hypothesis, not as a Netzverträglichkeitsprüfung or capacity proof.",
|
|
552
|
+
"OSM coverage for German distribution grids is incomplete; missing substations, lines, or voltage tags do not prove absence.",
|
|
553
|
+
];
|
|
554
|
+
if (totalSubstations === 0 && nodes === 0 && edges === 0) {
|
|
555
|
+
warnings.push("No visible grid objects were found in the requested OSM scope.");
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
evidenceType: "osm_visible_grid_context",
|
|
559
|
+
hypothesisStrength,
|
|
560
|
+
coverageLabel,
|
|
561
|
+
totalSubstations,
|
|
562
|
+
topologyNodes: nodes,
|
|
563
|
+
topologyEdges: edges,
|
|
564
|
+
answerGuidance: "Use this evidence to make ZNP, voltage-level, and siting hypotheses more concrete. Do not claim actual remaining capacity, protection settings, switching state, or asset ownership unless separate Cernion/operator evidence supports it.",
|
|
565
|
+
warnings,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async function queryGridContext(config, request, signal) {
|
|
569
|
+
const query = normalizeGridContextQuery(request);
|
|
570
|
+
const common = {
|
|
571
|
+
...(query.location ? { location: query.location } : {}),
|
|
572
|
+
...(query.boundingBox ? { boundingBox: query.boundingBox } : {}),
|
|
573
|
+
...(query.gridOperator ? { gridOperator: query.gridOperator } : {}),
|
|
574
|
+
...(query.gridOperatorId ? { gridOperatorId: query.gridOperatorId } : {}),
|
|
575
|
+
...(query.voltageLevel ? { voltageLevel: query.voltageLevel } : {}),
|
|
576
|
+
};
|
|
577
|
+
const substations = query.includeSubstations === true
|
|
578
|
+
? await requestCernionDegraded(config, "/api/osm-geo/substation-finder", {
|
|
579
|
+
method: "POST",
|
|
580
|
+
body: {
|
|
581
|
+
...common,
|
|
582
|
+
maxResults: query.maxResults,
|
|
583
|
+
include_geometry: query.includeGeometry,
|
|
584
|
+
},
|
|
585
|
+
signal,
|
|
586
|
+
})
|
|
587
|
+
: null;
|
|
588
|
+
const topology = query.includeTopology === true
|
|
589
|
+
? await requestCernionDegraded(config, "/api/osm-geo/grid-topology", {
|
|
590
|
+
method: "POST",
|
|
591
|
+
body: {
|
|
592
|
+
...common,
|
|
593
|
+
includeGraphData: query.includeGraphData,
|
|
594
|
+
},
|
|
595
|
+
signal,
|
|
596
|
+
})
|
|
597
|
+
: null;
|
|
598
|
+
return {
|
|
599
|
+
kind: "grid_context",
|
|
600
|
+
source: "osm_geo",
|
|
601
|
+
query,
|
|
602
|
+
evidenceAssessment: assessGridContextEvidence(substations, topology),
|
|
603
|
+
results: {
|
|
604
|
+
substations,
|
|
605
|
+
topology,
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
async function requestCernionDegraded(config, path, options = {}) {
|
|
610
|
+
try {
|
|
611
|
+
return await requestCernion(config, path, options);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
return {
|
|
615
|
+
isError: true,
|
|
616
|
+
error: {
|
|
617
|
+
code: "cernion_request_failed",
|
|
618
|
+
message: error instanceof Error ? error.message : String(error),
|
|
619
|
+
},
|
|
620
|
+
structuredContent: {
|
|
621
|
+
path,
|
|
622
|
+
degraded: true,
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function sleep(ms, signal) {
|
|
628
|
+
return new Promise((resolve, reject) => {
|
|
629
|
+
if (signal?.aborted) {
|
|
630
|
+
reject(new Error("Cernion Knowledge RAG polling aborted."));
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
const timer = setTimeout(resolve, ms);
|
|
634
|
+
signal?.addEventListener("abort", () => {
|
|
635
|
+
clearTimeout(timer);
|
|
636
|
+
reject(new Error("Cernion Knowledge RAG polling aborted."));
|
|
637
|
+
}, { once: true });
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
async function pollCernionJobResult(config, jobId, maxWaitMs, signal) {
|
|
641
|
+
const deadline = Date.now() + Math.max(1000, maxWaitMs);
|
|
642
|
+
let lastStatus = null;
|
|
643
|
+
while (Date.now() < deadline) {
|
|
644
|
+
const status = await requestCernion(config, `/api/jobs/${encodeURIComponent(jobId)}/status`, {
|
|
645
|
+
method: "GET",
|
|
646
|
+
signal,
|
|
647
|
+
});
|
|
648
|
+
lastStatus = status;
|
|
649
|
+
if (isObject(status) && status.status === "completed") {
|
|
650
|
+
const resultUrl = typeof status.resultUrl === "string" && status.resultUrl.startsWith("/api/")
|
|
651
|
+
? status.resultUrl
|
|
652
|
+
: `/api/jobs/${encodeURIComponent(jobId)}/result`;
|
|
653
|
+
return requestCernion(config, resultUrl, { method: "GET", signal });
|
|
654
|
+
}
|
|
655
|
+
if (isObject(status) && status.status === "error") {
|
|
656
|
+
return {
|
|
657
|
+
isError: true,
|
|
658
|
+
error: {
|
|
659
|
+
code: "cernion_knowledge_rag_job_error",
|
|
660
|
+
status: "error",
|
|
661
|
+
},
|
|
662
|
+
structuredContent: status,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
await sleep(500, signal);
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
kind: "domain_knowledge_job",
|
|
669
|
+
source: "knowledge_rag",
|
|
670
|
+
status: "pending",
|
|
671
|
+
message: "Cernion Knowledge RAG job is still running. Poll resultUrl later if needed.",
|
|
672
|
+
jobId,
|
|
673
|
+
statusUrl: `/api/jobs/${encodeURIComponent(jobId)}/status`,
|
|
674
|
+
resultUrl: `/api/jobs/${encodeURIComponent(jobId)}/result`,
|
|
675
|
+
lastStatus,
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
async function queryDomainKnowledge(config, request, signal) {
|
|
679
|
+
const body = normalizeDomainKnowledgeQuery(request);
|
|
680
|
+
const initial = await requestCernion(config, "/api/knowledge-rag/query", {
|
|
681
|
+
method: "POST",
|
|
682
|
+
body,
|
|
683
|
+
signal,
|
|
684
|
+
});
|
|
685
|
+
if (!isObject(initial) || initial.isError === true || !initial.jobId || request.waitForResult === false) {
|
|
686
|
+
return {
|
|
687
|
+
kind: "domain_knowledge",
|
|
688
|
+
source: "knowledge_rag",
|
|
689
|
+
query: body,
|
|
690
|
+
evidenceAssessment: assessDomainKnowledgeEvidence(body.query, initial),
|
|
691
|
+
result: initial,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
const maxWaitMs = request.maxWaitMs === undefined
|
|
695
|
+
? Math.min(requireConfig(config).timeoutMs, 30000)
|
|
696
|
+
: Math.max(1000, Math.min(60000, Number(request.maxWaitMs)));
|
|
697
|
+
const result = await pollCernionJobResult(config, String(initial.jobId), maxWaitMs, signal);
|
|
698
|
+
return {
|
|
699
|
+
kind: "domain_knowledge",
|
|
700
|
+
source: "knowledge_rag",
|
|
701
|
+
query: body,
|
|
702
|
+
job: initial,
|
|
703
|
+
evidenceAssessment: assessDomainKnowledgeEvidence(body.query, result),
|
|
704
|
+
result,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
async function executeEvidenceEndpointPlan(config, plan, signal) {
|
|
708
|
+
if (!isRestProxyAllowed(config)) {
|
|
709
|
+
throw new Error("Cernion read-only REST proxy is disabled by configuration.");
|
|
710
|
+
}
|
|
711
|
+
const validated = validateEvidenceEndpointPlan(plan);
|
|
712
|
+
if (validated.method === "GET") {
|
|
713
|
+
const params = normalizeReadOnlyGetParams(validated.path, validated.params);
|
|
714
|
+
const result = await requestCernion(config, buildReadOnlyGetPath(validated.path, validated.params), {
|
|
715
|
+
method: "GET",
|
|
716
|
+
signal,
|
|
717
|
+
});
|
|
718
|
+
return annotateAssetListResult(result, validated.path, params);
|
|
719
|
+
}
|
|
720
|
+
return requestCernion(config, validated.path, {
|
|
721
|
+
method: "POST",
|
|
722
|
+
body: validated.body,
|
|
723
|
+
signal,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
async function requestCernion(config, path, options = {}) {
|
|
727
|
+
const { baseUrl, bearerToken, timeoutMs } = requireConfig(config);
|
|
728
|
+
const controller = new AbortController();
|
|
729
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
730
|
+
const signal = combineAbortSignals(options.signal ? [options.signal, controller.signal] : [controller.signal]);
|
|
731
|
+
try {
|
|
732
|
+
const response = await fetch(buildUrl(baseUrl, path), {
|
|
733
|
+
method: options.method || "GET",
|
|
734
|
+
headers: {
|
|
735
|
+
authorization: `Bearer ${bearerToken}`,
|
|
736
|
+
accept: "application/json",
|
|
737
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
738
|
+
},
|
|
739
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
740
|
+
signal,
|
|
741
|
+
});
|
|
742
|
+
const text = await response.text();
|
|
743
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
744
|
+
const safeParsed = scrubSecretValues(parsed, bearerToken);
|
|
745
|
+
if (!response.ok) {
|
|
746
|
+
return {
|
|
747
|
+
isError: true,
|
|
748
|
+
error: {
|
|
749
|
+
code: "cernion_http_error",
|
|
750
|
+
status: response.status,
|
|
751
|
+
statusText: response.statusText,
|
|
752
|
+
},
|
|
753
|
+
structuredContent: safeParsed,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
return safeParsed;
|
|
757
|
+
}
|
|
758
|
+
finally {
|
|
759
|
+
clearTimeout(timeout);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
async function requestCernionProcess(config, path, options = {}) {
|
|
763
|
+
const { baseUrl, bearerToken, timeoutMs } = requireProcessConfig(config);
|
|
764
|
+
const controller = new AbortController();
|
|
765
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
766
|
+
const signal = combineAbortSignals(options.signal ? [options.signal, controller.signal] : [controller.signal]);
|
|
767
|
+
try {
|
|
768
|
+
const response = await fetch(buildUrl(baseUrl, path), {
|
|
769
|
+
method: options.method || "GET",
|
|
770
|
+
headers: {
|
|
771
|
+
authorization: `Bearer ${bearerToken}`,
|
|
772
|
+
accept: "application/json",
|
|
773
|
+
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
|
774
|
+
},
|
|
775
|
+
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
|
776
|
+
signal,
|
|
777
|
+
});
|
|
778
|
+
const text = await response.text();
|
|
779
|
+
const parsed = text ? JSON.parse(text) : null;
|
|
780
|
+
const safeParsed = scrubSecretValues(parsed, bearerToken);
|
|
781
|
+
if (!response.ok) {
|
|
782
|
+
return {
|
|
783
|
+
isError: true,
|
|
784
|
+
error: {
|
|
785
|
+
code: "cernion_http_error",
|
|
786
|
+
status: response.status,
|
|
787
|
+
statusText: response.statusText,
|
|
788
|
+
},
|
|
789
|
+
structuredContent: safeParsed,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
return safeParsed;
|
|
793
|
+
}
|
|
794
|
+
finally {
|
|
795
|
+
clearTimeout(timeout);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
function scrubSecretValues(value, token) {
|
|
799
|
+
const serialized = JSON.stringify(value);
|
|
800
|
+
if (!serialized)
|
|
801
|
+
return value;
|
|
802
|
+
if (!token)
|
|
803
|
+
return value;
|
|
804
|
+
const scrubbed = token ? serialized.split(token).join("[redacted]") : serialized;
|
|
805
|
+
return JSON.parse(scrubbed);
|
|
806
|
+
}
|
|
807
|
+
export default defineToolPlugin({
|
|
808
|
+
id: "cernion-energy-tools-sidecar",
|
|
809
|
+
name: "Cernion Energy Tools Sidecar",
|
|
810
|
+
description: "Expose Cernion Energy Tools to OpenClaw through separated evidence, knowledge, process-intake, and read-only REST boundaries.",
|
|
811
|
+
configSchema,
|
|
812
|
+
tools: (tool) => [
|
|
813
|
+
tool({
|
|
814
|
+
name: "cernion_query_domain_knowledge",
|
|
815
|
+
label: "Query Cernion Fachwissen",
|
|
816
|
+
description: "Query Cernion Knowledge RAG for read-only regulatory, procedural, and fachliche domain knowledge before using web search or operative backend hydration. Use this for laws, BNetzA guidance, Verfahrensanweisungen, roles, obligations, definitions, and consulting-at-the-job context. Treat the returned evidenceAssessment as binding primary-source support, not as a judgment on Cernion's domain knowledge quality: if evidenceAdequacy is low, do not answer legal/procedural duties as settled from Cernion primary sources; say Cernion returned useful domain/strategy knowledge but not enough primary-source support for hard obligations, and avoid filling gaps from model memory or web search unless the user explicitly asks.",
|
|
817
|
+
parameters: Type.Object({
|
|
818
|
+
queryType: Type.Optional(Type.String({ description: "Knowledge RAG mode: semantic, scroll, fetch, or collection_info. Defaults to semantic." })),
|
|
819
|
+
query: Type.Optional(Type.String({ description: "Natural-language fachliche/regulatory knowledge question. Required for semantic lookup." })),
|
|
820
|
+
limit: Type.Optional(Type.Number({ description: "Maximum results, 1..100. Defaults to 10." })),
|
|
821
|
+
scoreThreshold: Type.Optional(Type.Number({ description: "Optional semantic score threshold." })),
|
|
822
|
+
ids: Type.Optional(Type.Array(Type.Union([Type.String(), Type.Number()]), { description: "Point ids for fetch mode." })),
|
|
823
|
+
offset: Type.Optional(Type.Any({ description: "Optional offset for scroll mode." })),
|
|
824
|
+
filter: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Optional Qdrant-style metadata filter, for example authority/docType/status." })),
|
|
825
|
+
withPayload: Type.Optional(Type.Boolean({ description: "Return document payload/metadata when available. Defaults to true for consulting evidence." })),
|
|
826
|
+
withVectors: Type.Optional(Type.Boolean({ description: "Return vectors. Defaults to false and should usually stay false." })),
|
|
827
|
+
waitForResult: Type.Optional(Type.Boolean({ description: "Wait briefly for async Knowledge RAG job completion. Defaults to true." })),
|
|
828
|
+
maxWaitMs: Type.Optional(Type.Number({ description: "Maximum wait time for async job result, bounded to 1..60 seconds." })),
|
|
829
|
+
}),
|
|
830
|
+
execute: async (params, config, context) => {
|
|
831
|
+
const result = await queryDomainKnowledge(config, params, context.signal);
|
|
832
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
833
|
+
},
|
|
834
|
+
}),
|
|
835
|
+
tool({
|
|
836
|
+
name: "cernion_query_grid_context",
|
|
837
|
+
label: "Query Cernion OSM Grid Context",
|
|
838
|
+
description: "Query Cernion OSM Geo endpoints for read-only visible grid infrastructure context such as substations, transformers, voltage levels, lines, and topology metrics. Use this for ZNP, Netzanschluss, siting, fNAV, charging-hub, data-center, storage, PV, or voltage-level hypotheses before making concrete statements about likely critical network areas. Treat the result as OSM-based hypothesis evidence only: it can make planning assumptions more concrete, but it is not a capacity proof, Netzverträglichkeitsprüfung, switching-state model, or complete operator asset inventory. For data centers and other large loads, do not rank sites from OSM or grid-expansion proximity alone; explicit grid-connection availability maps, published connection capacity, and operator-confirmed Anschlusskapazität outrank generic Ausbau or proximity evidence.",
|
|
839
|
+
parameters: Type.Object({
|
|
840
|
+
location: Type.Optional(Type.String({ description: "Area name, municipality, city, or district, e.g. 'Sinsheim'." })),
|
|
841
|
+
boundingBox: Type.Optional(Type.Record(Type.String(), Type.Any(), {
|
|
842
|
+
description: "Optional geographic bounding box with north/south/east/west values.",
|
|
843
|
+
})),
|
|
844
|
+
gridOperator: Type.Optional(Type.String({ description: "Optional grid operator name to scope/fallback OSM area lookup." })),
|
|
845
|
+
gridOperatorId: Type.Optional(Type.String({ description: "Optional MaStR/VNB grid operator id if already known." })),
|
|
846
|
+
voltageLevel: Type.Optional(Type.String({ description: "Optional voltage-level filter: NS, MS, HS, or EHS." })),
|
|
847
|
+
includeSubstations: Type.Optional(Type.Boolean({ description: "Call /api/osm-geo/substation-finder. Defaults to true." })),
|
|
848
|
+
includeTopology: Type.Optional(Type.Boolean({
|
|
849
|
+
description: "Call /api/osm-geo/grid-topology. Defaults to false; enable for candidate-area drill-down rather than broad county searches.",
|
|
850
|
+
})),
|
|
851
|
+
includeGeometry: Type.Optional(Type.Boolean({ description: "Include substation polygon geometry when available. Defaults to false." })),
|
|
852
|
+
includeGraphData: Type.Optional(Type.Boolean({ description: "Include raw topology graph data. Defaults to false." })),
|
|
853
|
+
maxResults: Type.Optional(Type.Number({ description: "Maximum returned substation records, 1..1000. Defaults to 200." })),
|
|
854
|
+
}),
|
|
855
|
+
execute: async (params, config, context) => {
|
|
856
|
+
const result = await queryGridContext(config, params, context.signal);
|
|
857
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
858
|
+
},
|
|
859
|
+
}),
|
|
860
|
+
tool({
|
|
861
|
+
name: "cernion_route_evidence",
|
|
862
|
+
label: "Route Cernion Evidence",
|
|
863
|
+
description: "Ask Cernion's backend Evidence Router for read-only endpoint recommendations and fachliche result semantics. Cernion does not execute these endpoints or synthesize the final answer.",
|
|
864
|
+
parameters: Type.Object({
|
|
865
|
+
question: Type.String({ description: "Natural-language request describing the evidence needed." }),
|
|
866
|
+
context: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Optional canonical input values such as location, dateFrom, dateTo, assetType, or region." })),
|
|
867
|
+
}),
|
|
868
|
+
execute: async ({ question, context: requestContext = {} }, config, context) => {
|
|
869
|
+
const result = await routeEvidence(config, { question, context: requestContext }, context.signal);
|
|
870
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
871
|
+
},
|
|
872
|
+
}),
|
|
873
|
+
tool({
|
|
874
|
+
name: "cernion_execute_evidence_endpoint",
|
|
875
|
+
label: "Execute Cernion Evidence Endpoint",
|
|
876
|
+
description: "Execute one read-only GET or POST evidence endpoint recommended by cernion_route_evidence. Requires Cernion policy.readOnly=true and sideEffects=none, blocks process/admin/token/HITL/provider-tool paths, and returns scrubbed structured results for OpenClaw-side transformation. For Cernion asset-list GET endpoints, the Sidecar sets an explicit default limit when none is provided and adds _sidecar pagination/export guidance when the returned rows exhaust the limit.",
|
|
877
|
+
parameters: Type.Object({
|
|
878
|
+
method: Type.Optional(Type.String({ description: "HTTP method from the recommended endpoint. Only GET or POST is allowed." })),
|
|
879
|
+
path: Type.String({ description: "Relative Cernion API path from the recommended endpoint, e.g. /api/energy-market/co2-intensity." }),
|
|
880
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Query parameters from the recommended endpoint." })),
|
|
881
|
+
query: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Alias for params, used by Evidence Router recommendation envelopes." })),
|
|
882
|
+
body: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Optional POST body. Defaults to query/params for POST evidence endpoints." })),
|
|
883
|
+
resultKind: Type.Optional(Type.String({ description: "Evidence result kind, e.g. time_series, asset_table, market_signal." })),
|
|
884
|
+
purpose: Type.Optional(Type.String({ description: "Purpose/id from the Evidence Router recommendation." })),
|
|
885
|
+
policy: Type.Record(Type.String(), Type.Any(), { description: "Policy metadata from Cernion; must include readOnly=true." }),
|
|
886
|
+
}),
|
|
887
|
+
execute: async (plan, config, context) => {
|
|
888
|
+
const result = await executeEvidenceEndpointPlan(config, plan, context.signal);
|
|
889
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
890
|
+
},
|
|
891
|
+
}),
|
|
892
|
+
tool({
|
|
893
|
+
name: "cernion_prepare_process_intent",
|
|
894
|
+
label: "Prepare Cernion Process Intent",
|
|
895
|
+
description: "Send user-provided process data or a write/process intent to Cernion's separate Process Intake boundary. Creates only a pending_confirmation receipt; it does not execute the process intent.",
|
|
896
|
+
parameters: Type.Object({
|
|
897
|
+
operationFamily: Type.String({ description: "Generic process family, excluding Cernion-reserved families that have dedicated prepare actions." }),
|
|
898
|
+
proposedAction: Type.String({ description: "Requested process action. Cernion rejects registered read-only actions here." }),
|
|
899
|
+
targetType: Type.Optional(Type.String({ description: "Optional process target type." })),
|
|
900
|
+
targetId: Type.Optional(Type.String({ description: "Optional process target id." })),
|
|
901
|
+
inputSummary: Type.Optional(Type.String({ description: "Optional concise summary of user-provided process data." })),
|
|
902
|
+
payload: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Structured process data supplied by the user." })),
|
|
903
|
+
risk: Type.Optional(Type.String({ description: "Risk classification hint: low, medium, or high." })),
|
|
904
|
+
reason: Type.Optional(Type.String({ description: "Reason or user instruction behind this intake." })),
|
|
905
|
+
correlationId: Type.Optional(Type.String({ description: "Optional correlation id." })),
|
|
906
|
+
decisionFrameId: Type.Optional(Type.String({ description: "Optional decision frame id." })),
|
|
907
|
+
}),
|
|
908
|
+
execute: async (params, config, context) => {
|
|
909
|
+
const result = await requestCernionProcess(config, "/api/copilot-process/intents", {
|
|
910
|
+
method: "POST",
|
|
911
|
+
body: params,
|
|
912
|
+
signal: context.signal,
|
|
913
|
+
});
|
|
914
|
+
const processToken = requireProcessConfig(config).bearerToken;
|
|
915
|
+
return scrubSecretValues(result, processToken);
|
|
916
|
+
},
|
|
917
|
+
}),
|
|
918
|
+
tool({
|
|
919
|
+
name: "cernion_ask",
|
|
920
|
+
label: "Ask Cernion",
|
|
921
|
+
description: "Ask Cernion through the generic provider gate. Cernion may answer directly or return structured capability, blueprint, evidence, and read-only REST execution-plan hints that OpenClaw can reuse.",
|
|
922
|
+
parameters: Type.Object({
|
|
923
|
+
query: Type.String({ description: "Natural-language request or task context to resolve inside Cernion." }),
|
|
924
|
+
context: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Optional tenant, user, or session context." })),
|
|
925
|
+
inputs: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Optional structured inputs already known to OpenClaw." })),
|
|
926
|
+
}),
|
|
927
|
+
execute: async ({ query, context: requestContext = {}, inputs = {} }, config, context) => {
|
|
928
|
+
const result = await requestCernion(config, "/api/agent-sidecar/mcp/tools/cernion.ask/call", {
|
|
929
|
+
method: "POST",
|
|
930
|
+
body: { arguments: { question: query, query, context: requestContext, inputs } },
|
|
931
|
+
signal: context.signal,
|
|
932
|
+
});
|
|
933
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
934
|
+
},
|
|
935
|
+
}),
|
|
936
|
+
tool({
|
|
937
|
+
name: "cernion_sidecar_descriptor",
|
|
938
|
+
label: "Cernion Sidecar Descriptor",
|
|
939
|
+
description: "Load the Cernion Energy Tools Sidecar descriptor from Cernion without exposing bearer tokens.",
|
|
940
|
+
parameters: Type.Object({}),
|
|
941
|
+
execute: async (_params, config, context) => {
|
|
942
|
+
const result = await requestCernion(config, "/api/agent-sidecar/descriptor", { signal: context.signal });
|
|
943
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
944
|
+
},
|
|
945
|
+
}),
|
|
946
|
+
tool({
|
|
947
|
+
name: "cernion_sidecar_tools",
|
|
948
|
+
label: "Cernion Sidecar Tools",
|
|
949
|
+
description: "List Cernion Sidecar tools in the MCP/OpenClaw-compatible tools/list shape.",
|
|
950
|
+
parameters: Type.Object({}),
|
|
951
|
+
execute: async (_params, config, context) => {
|
|
952
|
+
const result = await requestCernion(config, "/api/agent-sidecar/mcp/tools", { signal: context.signal });
|
|
953
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
954
|
+
},
|
|
955
|
+
}),
|
|
956
|
+
tool({
|
|
957
|
+
name: "cernion_sidecar_call",
|
|
958
|
+
label: "Call Cernion Sidecar Tool",
|
|
959
|
+
description: "Call one curated read-only/advisory Cernion Sidecar tool through the provider policy gate.",
|
|
960
|
+
parameters: Type.Object({
|
|
961
|
+
name: Type.String({ description: "Provider tool name, e.g. cernion.list_readonly_capabilities." }),
|
|
962
|
+
arguments: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Tool arguments forwarded to Cernion." })),
|
|
963
|
+
}),
|
|
964
|
+
execute: async ({ name, arguments: toolArguments = {} }, config, context) => {
|
|
965
|
+
const result = await requestCernion(config, `/api/agent-sidecar/mcp/tools/${encodeURIComponent(name)}/call`, {
|
|
966
|
+
method: "POST",
|
|
967
|
+
body: { arguments: toolArguments },
|
|
968
|
+
signal: context.signal,
|
|
969
|
+
});
|
|
970
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
971
|
+
},
|
|
972
|
+
}),
|
|
973
|
+
tool({
|
|
974
|
+
name: "cernion_resolve_capabilities",
|
|
975
|
+
label: "Resolve Cernion Capabilities",
|
|
976
|
+
description: "Resolve capability cluster heads from the llm.txt manifest to full Cernion capability details via GET /api/_agent/capabilities. Optionally filter by canonical manifest domain.",
|
|
977
|
+
parameters: Type.Object({
|
|
978
|
+
domain: Type.Optional(Type.String({ description: "Optional canonical manifest domain, e.g. 'redispatch' or 'grid-ops'." })),
|
|
979
|
+
}),
|
|
980
|
+
execute: async ({ domain }, config, context) => {
|
|
981
|
+
const result = await requestCernion(config, buildQueryPath("/api/_agent/capabilities", { domain }), {
|
|
982
|
+
method: "GET",
|
|
983
|
+
signal: context.signal,
|
|
984
|
+
});
|
|
985
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
986
|
+
},
|
|
987
|
+
}),
|
|
988
|
+
tool({
|
|
989
|
+
name: "cernion_resolve_capability",
|
|
990
|
+
label: "Resolve Cernion Capability",
|
|
991
|
+
description: "Resolve one Cernion capability id from the llm.txt manifest to its full capability detail via GET /api/_agent/capabilities/:name.",
|
|
992
|
+
parameters: Type.Object({
|
|
993
|
+
name: Type.String({ description: "Capability id, e.g. 'redispatch_asset_register'." }),
|
|
994
|
+
}),
|
|
995
|
+
execute: async ({ name }, config, context) => {
|
|
996
|
+
const result = await requestCernion(config, `/api/_agent/capabilities/${encodeURIComponent(name)}`, {
|
|
997
|
+
method: "GET",
|
|
998
|
+
signal: context.signal,
|
|
999
|
+
});
|
|
1000
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
1001
|
+
},
|
|
1002
|
+
}),
|
|
1003
|
+
tool({
|
|
1004
|
+
name: "cernion_resolve_operations",
|
|
1005
|
+
label: "Resolve Cernion Operations",
|
|
1006
|
+
description: "Resolve operation clusters from the llm.txt manifest to deduplicated Cernion OpenAPI operation details via GET /api/_agent/operations. Duplicate operationIds are returned as one canonical path with aliases.",
|
|
1007
|
+
parameters: Type.Object({
|
|
1008
|
+
domain: Type.Optional(Type.String({ description: "Optional canonical manifest domain, e.g. 'redispatch' or 'grid-ops'." })),
|
|
1009
|
+
}),
|
|
1010
|
+
execute: async ({ domain }, config, context) => {
|
|
1011
|
+
const result = await requestCernion(config, buildQueryPath("/api/_agent/operations", { domain }), {
|
|
1012
|
+
method: "GET",
|
|
1013
|
+
signal: context.signal,
|
|
1014
|
+
});
|
|
1015
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
1016
|
+
},
|
|
1017
|
+
}),
|
|
1018
|
+
tool({
|
|
1019
|
+
name: "cernion_execute_rest_plan",
|
|
1020
|
+
label: "Execute Cernion REST Plan",
|
|
1021
|
+
description: "Proxy one read-only Cernion REST execution plan emitted by cernion.ask or the Cernion blueprint/capability runtime. The Sidecar supplies the configured base URL and bearer token, validates the plan as GET-only, and returns scrubbed structured results. For Cernion asset-list endpoints, it sets an explicit default limit when none is provided and adds _sidecar pagination/export guidance when the returned rows exhaust the limit.",
|
|
1022
|
+
parameters: Type.Object({
|
|
1023
|
+
method: Type.Optional(Type.String({ description: "HTTP method from the execution plan. Only GET is allowed." })),
|
|
1024
|
+
path: Type.String({ description: "Relative Cernion API path from the execution plan, e.g. /api/assets/solar." }),
|
|
1025
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Query parameters from the execution plan." })),
|
|
1026
|
+
query: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Alias for params, used by some Cernion plan envelopes." })),
|
|
1027
|
+
policy: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Optional policy metadata returned by Cernion." })),
|
|
1028
|
+
}),
|
|
1029
|
+
execute: async (plan, config, context) => {
|
|
1030
|
+
const result = await executeRestExecutionPlan(config, plan, context.signal);
|
|
1031
|
+
return scrubSecretValues(result, config.bearerToken);
|
|
1032
|
+
},
|
|
1033
|
+
}),
|
|
1034
|
+
tool({
|
|
1035
|
+
name: "cernion_api_request",
|
|
1036
|
+
label: "Cernion API Request",
|
|
1037
|
+
description: "Perform an authenticated read-only GET request directly against Cernion Energy Tools (CET). Must be used to resolve capabilities, operations, or query specific domain data (like assets.solar) following the llm.txt RESOLUTION PROTOCOL. For Cernion asset-list endpoints, the Sidecar sets an explicit default limit when none is provided and adds _sidecar pagination/export guidance when the returned rows exhaust the limit.",
|
|
1038
|
+
parameters: Type.Object({
|
|
1039
|
+
path: Type.String({ description: "The API path to call, e.g. '/api/_agent/capabilities', '/api/_agent/operations', '/api/assets/solar'." }),
|
|
1040
|
+
params: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Query parameters to append to the GET request." })),
|
|
1041
|
+
}),
|
|
1042
|
+
execute: async ({ path, params = {} }, config, context) => {
|
|
1043
|
+
const normalizedParams = normalizeReadOnlyGetParams(path, params);
|
|
1044
|
+
const result = await requestCernion(config, buildReadOnlyGetPath(path, params), {
|
|
1045
|
+
method: "GET",
|
|
1046
|
+
signal: context.signal,
|
|
1047
|
+
});
|
|
1048
|
+
return scrubSecretValues(annotateAssetListResult(result, path, normalizedParams), config.bearerToken);
|
|
1049
|
+
},
|
|
1050
|
+
}),
|
|
1051
|
+
],
|
|
1052
|
+
});
|
|
1053
|
+
export { buildQueryPath, buildUrl, executeEvidenceEndpointPlan, executeRestExecutionPlan, isRestProxyAllowed, normalizeDomainKnowledgeQuery, normalizeGridContextQuery, assessDomainKnowledgeEvidence, assessGridContextEvidence, pollCernionJobResult, queryGridContext, queryDomainKnowledge, requireConfig, requireProcessConfig, requestCernion, requestCernionProcess, routeEvidence, scrubSecretValues, validateEvidenceEndpointPlan, validateRestExecutionPlan, };
|