ocuclaw 1.2.4 → 1.3.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/README.md +18 -5
- package/dist/config/runtime-config.js +81 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +38 -182
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +754 -83
- package/dist/runtime/downstream-server.js +700 -534
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-update-service.js +216 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1209 -204
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +285 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1081 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +615 -24
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +746 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1147 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- package/package.json +12 -4
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// Per-session surface store for the glasses-UI tool. Extracted from
|
|
2
|
+
// glasses-ui-tool.ts (Phase 1) so this stateful logic can be unit-tested in
|
|
3
|
+
// isolation and so the tool file thins toward a schema+wiring layer (spec
|
|
4
|
+
// §Lifecycle detail — Surface/stack lifecycle store).
|
|
5
|
+
//
|
|
6
|
+
// Phase 2 evolves the Phase-1 createPendingRenderMap IN PLACE into
|
|
7
|
+
// createSurfaceStore (SINGLE store — there is never a second store alongside
|
|
8
|
+
// it). The decoupled lifecycle means a surface's lifetime is independent of
|
|
9
|
+
// any one tool call: resolve() settles the in-flight pending call but the
|
|
10
|
+
// surface entry PERSISTS (keyed by surfaceId), and a new register() does NOT
|
|
11
|
+
// preempt the previous surface. The legacy createPendingRenderMap name survives
|
|
12
|
+
// only as an alias (bottom of this file) for the Phase 1 import path.
|
|
13
|
+
|
|
14
|
+
// A terminal outcome ends the surface (teardown / exit). A nonterminal outcome
|
|
15
|
+
// (selected/back) keeps the surface alive and hands the turn to the agent.
|
|
16
|
+
const TERMINAL_RESULTS = new Set(["dismissed", "timeout", "glasses_disconnected", "preempted", "recipe_failed"]);
|
|
17
|
+
|
|
18
|
+
export function isTerminalOutcome(outcome) {
|
|
19
|
+
return !!(outcome && typeof outcome.result === "string" && TERMINAL_RESULTS.has(outcome.result));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSurfaceStore(deps = {}) {
|
|
23
|
+
const pauseCron = typeof deps.pauseCron === "function" ? deps.pauseCron : () => {};
|
|
24
|
+
const resumeCron = typeof deps.resumeCron === "function" ? deps.resumeCron : () => {};
|
|
25
|
+
const stopCron = typeof deps.stopCron === "function" ? deps.stopCron : () => {};
|
|
26
|
+
const mintSurfaceId =
|
|
27
|
+
typeof deps.mintSurfaceId === "function"
|
|
28
|
+
? deps.mintSurfaceId
|
|
29
|
+
: () => `ui-${Math.random().toString(36).slice(2, 10)}`;
|
|
30
|
+
// surfaceId -> { sessionKey, kind, pending: (resolver|null), lastContent,
|
|
31
|
+
// state, queuedEvent, exitLatched }
|
|
32
|
+
const bySurface = new Map();
|
|
33
|
+
const stackBySession = new Map(); // sessionKey -> [surfaceId, ...] (bottom→top)
|
|
34
|
+
|
|
35
|
+
function stackFor(sessionKey) {
|
|
36
|
+
let s = stackBySession.get(sessionKey);
|
|
37
|
+
if (!s) { s = []; stackBySession.set(sessionKey, s); }
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// A fresh entry starts un-latched with an empty queue. A `prior` entry (an
|
|
42
|
+
// in-place content swap of the SAME surfaceId — i.e. `replace`) carries its
|
|
43
|
+
// latched exit / last-wins queued event forward so reattach semantics are
|
|
44
|
+
// MOVE-INDEPENDENT: a terminal latched (or a nonterminal queued) during
|
|
45
|
+
// `visible_awaiting_agent` is honored on the agent's NEXT render whether that
|
|
46
|
+
// render is patch, replace (the schema DEFAULT) or push — never silently
|
|
47
|
+
// dropped by the rebuild. (Without this carry-forward, a `replace` rebuild
|
|
48
|
+
// would reset exitLatched=false / queuedEvent=null and onReattached would
|
|
49
|
+
// return "reattached" instead of "discarded_for_exit", failing to tear down.)
|
|
50
|
+
function makeEntry(sessionKey, kind, prior) {
|
|
51
|
+
return {
|
|
52
|
+
sessionKey, kind: kind || null, pending: null, lastContent: null,
|
|
53
|
+
state: "visible_pending",
|
|
54
|
+
queuedEvent: prior ? prior.queuedEvent : null,
|
|
55
|
+
exitLatched: prior ? !!prior.exitLatched : false,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function register(sessionKey, surfaceId, meta) {
|
|
60
|
+
return new Promise((resolve) => {
|
|
61
|
+
const existing = bySurface.get(surfaceId);
|
|
62
|
+
if (existing) {
|
|
63
|
+
// Re-attach to an existing surface: replace its pending resolver,
|
|
64
|
+
// keep the entry. No preempt of a different surface. Restore the
|
|
65
|
+
// pending state so a re-render returns to visible_pending before
|
|
66
|
+
// onReattached flushes any queued event / latched exit.
|
|
67
|
+
existing.pending = resolve;
|
|
68
|
+
if (meta && meta.kind) existing.kind = meta.kind;
|
|
69
|
+
existing.sessionKey = sessionKey;
|
|
70
|
+
existing.state = "visible_pending";
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const entry = makeEntry(sessionKey, meta && meta.kind ? meta.kind : null);
|
|
74
|
+
entry.pending = resolve;
|
|
75
|
+
bySurface.set(surfaceId, entry);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolve(surfaceId, outcome) {
|
|
80
|
+
const entry = bySurface.get(surfaceId);
|
|
81
|
+
if (!entry || !entry.pending) return false;
|
|
82
|
+
const pending = entry.pending;
|
|
83
|
+
entry.pending = null; // settle the call; surface entry persists
|
|
84
|
+
if (isTerminalOutcome(outcome)) {
|
|
85
|
+
entry.state = "exiting";
|
|
86
|
+
} else {
|
|
87
|
+
entry.state = "visible_awaiting_agent";
|
|
88
|
+
}
|
|
89
|
+
pending(outcome);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hasSurface(surfaceId) {
|
|
94
|
+
return bySurface.has(surfaceId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isPending(surfaceId) {
|
|
98
|
+
const entry = bySurface.get(surfaceId);
|
|
99
|
+
return !!(entry && entry.pending);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Carried over from createPendingRenderMap (Phase 1). drainSession/drainAll
|
|
103
|
+
// resolve any in-flight pending call; Task 11 extends them to also stop
|
|
104
|
+
// crons and clear the per-session stack. They are the only resolve path that
|
|
105
|
+
// also DELETES surfaces (terminal teardown), unlike resolve() above.
|
|
106
|
+
function drainSession(sessionKey, outcome) {
|
|
107
|
+
let n = 0;
|
|
108
|
+
for (const [surfaceId, entry] of [...bySurface]) {
|
|
109
|
+
if (entry.sessionKey !== sessionKey) continue;
|
|
110
|
+
const pending = entry.pending;
|
|
111
|
+
entry.pending = null;
|
|
112
|
+
bySurface.delete(surfaceId);
|
|
113
|
+
if (pending) { pending(outcome); n += 1; }
|
|
114
|
+
}
|
|
115
|
+
return n;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function drainAll(outcome) {
|
|
119
|
+
let n = 0;
|
|
120
|
+
for (const [surfaceId, entry] of [...bySurface]) {
|
|
121
|
+
const pending = entry.pending;
|
|
122
|
+
entry.pending = null;
|
|
123
|
+
bySurface.delete(surfaceId);
|
|
124
|
+
if (pending) { pending(outcome); n += 1; }
|
|
125
|
+
}
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function stateOf(surfaceId) {
|
|
130
|
+
const entry = bySurface.get(surfaceId);
|
|
131
|
+
return entry ? entry.state : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function queueEvent(surfaceId, event) {
|
|
135
|
+
const entry = bySurface.get(surfaceId);
|
|
136
|
+
if (!entry) return false;
|
|
137
|
+
if (isTerminalOutcome(event)) {
|
|
138
|
+
entry.exitLatched = true; // latched exit beats any queued nonterminal
|
|
139
|
+
entry.queuedEvent = event;
|
|
140
|
+
} else if (!entry.exitLatched) {
|
|
141
|
+
entry.queuedEvent = event; // last-wins among nonterminals
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isExitLatched(surfaceId) {
|
|
147
|
+
const entry = bySurface.get(surfaceId);
|
|
148
|
+
return !!(entry && entry.exitLatched);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function onReattached(surfaceId) {
|
|
152
|
+
const entry = bySurface.get(surfaceId);
|
|
153
|
+
if (!entry) return "no_surface";
|
|
154
|
+
if (entry.exitLatched) {
|
|
155
|
+
// A terminal latched during the agent's turn. Discard this render and
|
|
156
|
+
// resolve the freshly-established pending call (the re-attach render's
|
|
157
|
+
// promise) with the latched terminal outcome so the tool call settles
|
|
158
|
+
// instead of hanging, then tear down.
|
|
159
|
+
entry.state = "exiting";
|
|
160
|
+
const terminal = entry.queuedEvent || { result: "dismissed" };
|
|
161
|
+
entry.queuedEvent = null;
|
|
162
|
+
if (entry.pending) {
|
|
163
|
+
const pending = entry.pending;
|
|
164
|
+
entry.pending = null;
|
|
165
|
+
pending(terminal);
|
|
166
|
+
}
|
|
167
|
+
return "discarded_for_exit";
|
|
168
|
+
}
|
|
169
|
+
entry.state = "reattached";
|
|
170
|
+
const queued = entry.queuedEvent;
|
|
171
|
+
entry.queuedEvent = null;
|
|
172
|
+
if (queued && entry.pending) {
|
|
173
|
+
const pending = entry.pending;
|
|
174
|
+
entry.pending = null;
|
|
175
|
+
entry.state = "visible_awaiting_agent";
|
|
176
|
+
pending(queued);
|
|
177
|
+
}
|
|
178
|
+
return "reattached";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function topSurfaceId(sessionKey) {
|
|
182
|
+
const s = stackBySession.get(sessionKey);
|
|
183
|
+
return s && s.length ? s[s.length - 1] : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stackDepth(sessionKey) {
|
|
187
|
+
const s = stackBySession.get(sessionKey);
|
|
188
|
+
return s ? s.length : 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sessionForSurface(surfaceId) {
|
|
192
|
+
const entry = bySurface.get(surfaceId);
|
|
193
|
+
return entry ? entry.sessionKey : null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Derive the target surfaceId from the session's current top. The agent
|
|
197
|
+
// never supplies a surfaceId (spec §Core model — the plugin owns them,
|
|
198
|
+
// keyed by session + stack). Returns { mode, surfaceId } so the caller can
|
|
199
|
+
// render against the bound id.
|
|
200
|
+
function applyRender(sessionKey, params) {
|
|
201
|
+
const stack = stackFor(sessionKey);
|
|
202
|
+
const top = stack[stack.length - 1] || null;
|
|
203
|
+
// First render in a session (empty stack): create a root at depth 1,
|
|
204
|
+
// regardless of the requested update (there is nothing to patch/push onto).
|
|
205
|
+
if (!top) {
|
|
206
|
+
const id = mintSurfaceId();
|
|
207
|
+
stack.push(id);
|
|
208
|
+
bySurface.set(id, makeEntry(sessionKey, params && params.kind));
|
|
209
|
+
return { mode: "root", surfaceId: id };
|
|
210
|
+
}
|
|
211
|
+
const update = params && params.update === "patch" ? "patch"
|
|
212
|
+
: params && params.update === "push" ? "push"
|
|
213
|
+
: "replace";
|
|
214
|
+
if (update === "patch") {
|
|
215
|
+
// Re-attach in place: reuse the top id, keep its entry + cron. The
|
|
216
|
+
// caller sends a field patch; the body content slot is updated by the
|
|
217
|
+
// render send, not here.
|
|
218
|
+
const entry = bySurface.get(top);
|
|
219
|
+
if (entry && params && params.kind) entry.kind = params.kind;
|
|
220
|
+
if (entry) entry.state = "visible_pending";
|
|
221
|
+
return { mode: "patch", surfaceId: top };
|
|
222
|
+
}
|
|
223
|
+
if (update === "push") {
|
|
224
|
+
pauseCron(top);
|
|
225
|
+
const id = mintSurfaceId();
|
|
226
|
+
stack.push(id);
|
|
227
|
+
bySurface.set(id, makeEntry(sessionKey, params && params.kind));
|
|
228
|
+
return { mode: "push", surfaceId: id };
|
|
229
|
+
}
|
|
230
|
+
// replace: swap the current top's CONTENT in place, reusing its id (no
|
|
231
|
+
// new back-target, depth unchanged). The entry is reset to a fresh
|
|
232
|
+
// pending state; the cron for this slot is reset for new content. CARRY
|
|
233
|
+
// FORWARD the prior entry's latched exit / queued event (same surfaceId)
|
|
234
|
+
// so a terminal/nonterminal recorded during visible_awaiting_agent is
|
|
235
|
+
// still honored by the subsequent onReattached — reattach semantics must
|
|
236
|
+
// be move-independent (replace, the schema DEFAULT, must behave like
|
|
237
|
+
// patch here, not silently drop a latched exit).
|
|
238
|
+
const priorTop = bySurface.get(top);
|
|
239
|
+
stopCron(top);
|
|
240
|
+
bySurface.set(top, makeEntry(sessionKey, params && params.kind, priorTop));
|
|
241
|
+
return { mode: "replace", surfaceId: top };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function popBack(sessionKey) {
|
|
245
|
+
const stack = stackFor(sessionKey);
|
|
246
|
+
const child = stack.pop();
|
|
247
|
+
if (child) { stopCron(child); bySurface.delete(child); }
|
|
248
|
+
const parent = stack[stack.length - 1] || null;
|
|
249
|
+
if (parent) resumeCron(parent);
|
|
250
|
+
return parent;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function exit(sessionKey) {
|
|
254
|
+
const stack = stackFor(sessionKey);
|
|
255
|
+
for (const id of stack) { stopCron(id); bySurface.delete(id); }
|
|
256
|
+
stackBySession.set(sessionKey, []);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function sessionKeys() {
|
|
261
|
+
return [...stackBySession.keys()];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
register, resolve, hasSurface, isPending, drainSession, drainAll,
|
|
266
|
+
stateOf, queueEvent, isExitLatched, onReattached,
|
|
267
|
+
applyRender, popBack, exit, topSurfaceId, stackDepth, sessionKeys, sessionForSurface,
|
|
268
|
+
_bySurface: bySurface,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Backwards-compatible alias. Phase 1 extracted createPendingRenderMap; Phase 2
|
|
273
|
+
// evolved it into createSurfaceStore (single store — see the SINGLE-STORE
|
|
274
|
+
// EVOLUTION note in the plan). The old name survives only as an alias so the
|
|
275
|
+
// Phase 1 import path (glasses-ui-tool re-export) keeps resolving. There is
|
|
276
|
+
// never a separate createPendingRenderMap *instance* — both names construct the
|
|
277
|
+
// one evolved store.
|
|
278
|
+
export const createPendingRenderMap = createSurfaceStore;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Template engine for glasses UI refresh recipes.
|
|
2
|
+
// Pure functions. No I/O. Parses {{path | filter:arg | filter:arg}} expressions,
|
|
3
|
+
// resolves paths against a data object (plus an optional previous-tick object
|
|
4
|
+
// exposed under {{previous.*}}), and applies a small filter set sized exactly
|
|
5
|
+
// to the spec's stated use cases.
|
|
6
|
+
|
|
7
|
+
const KNOWN_FILTERS = new Set([
|
|
8
|
+
"trim",
|
|
9
|
+
"lower",
|
|
10
|
+
"upper",
|
|
11
|
+
"int",
|
|
12
|
+
"round",
|
|
13
|
+
"percent",
|
|
14
|
+
"truncate",
|
|
15
|
+
"default",
|
|
16
|
+
"prefix",
|
|
17
|
+
"minus",
|
|
18
|
+
"plus",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Filters that require a numeric arg.
|
|
22
|
+
const NUMERIC_ARG_FILTERS = new Set(["round", "truncate"]);
|
|
23
|
+
// Filters that require a string arg (a quoted literal in the template).
|
|
24
|
+
const STRING_ARG_FILTERS = new Set(["default", "prefix"]);
|
|
25
|
+
// Filters that require a path arg (resolves against the data object).
|
|
26
|
+
const PATH_ARG_FILTERS = new Set(["minus", "plus"]);
|
|
27
|
+
|
|
28
|
+
function resolvePath(path, data, previous) {
|
|
29
|
+
if (typeof path !== "string" || path === "") return undefined;
|
|
30
|
+
const segments = path.split(".");
|
|
31
|
+
let cursor;
|
|
32
|
+
if (segments[0] === "previous") {
|
|
33
|
+
cursor = previous;
|
|
34
|
+
segments.shift();
|
|
35
|
+
} else {
|
|
36
|
+
cursor = data;
|
|
37
|
+
}
|
|
38
|
+
for (const segment of segments) {
|
|
39
|
+
if (cursor === null || cursor === undefined) return undefined;
|
|
40
|
+
if (Array.isArray(cursor)) {
|
|
41
|
+
const idx = Number(segment);
|
|
42
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= cursor.length) return undefined;
|
|
43
|
+
cursor = cursor[idx];
|
|
44
|
+
} else if (typeof cursor === "object") {
|
|
45
|
+
cursor = cursor[segment];
|
|
46
|
+
} else {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return cursor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseFilter(filterSrc) {
|
|
54
|
+
// Format: `name` or `name:arg` where arg can be:
|
|
55
|
+
// - a quoted string: `default:"x"` or `prefix:"+"`
|
|
56
|
+
// - a number: `round:2` or `truncate:18`
|
|
57
|
+
// - a path: `minus:previous.value`
|
|
58
|
+
const colonIdx = filterSrc.indexOf(":");
|
|
59
|
+
const name = (colonIdx === -1 ? filterSrc : filterSrc.slice(0, colonIdx)).trim();
|
|
60
|
+
const rawArg = colonIdx === -1 ? "" : filterSrc.slice(colonIdx + 1).trim();
|
|
61
|
+
if (!KNOWN_FILTERS.has(name)) {
|
|
62
|
+
return { ok: false, code: "refresh_template_invalid", message: `unknown filter: ${name}` };
|
|
63
|
+
}
|
|
64
|
+
if (NUMERIC_ARG_FILTERS.has(name)) {
|
|
65
|
+
const n = Number(rawArg);
|
|
66
|
+
if (rawArg === "" || !Number.isFinite(n)) {
|
|
67
|
+
return { ok: false, code: "refresh_template_invalid", message: `filter ${name} requires numeric arg, got: ${JSON.stringify(rawArg)}` };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, name, arg: n };
|
|
70
|
+
}
|
|
71
|
+
if (STRING_ARG_FILTERS.has(name)) {
|
|
72
|
+
const m = rawArg.match(/^"([^"]*)"$/);
|
|
73
|
+
if (!m) {
|
|
74
|
+
return { ok: false, code: "refresh_template_invalid", message: `filter ${name} requires quoted-string arg, got: ${JSON.stringify(rawArg)}` };
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, name, arg: m[1] };
|
|
77
|
+
}
|
|
78
|
+
if (PATH_ARG_FILTERS.has(name)) {
|
|
79
|
+
if (!rawArg) {
|
|
80
|
+
return { ok: false, code: "refresh_template_invalid", message: `filter ${name} requires a path arg` };
|
|
81
|
+
}
|
|
82
|
+
return { ok: true, name, arg: rawArg };
|
|
83
|
+
}
|
|
84
|
+
// No-arg filters.
|
|
85
|
+
return { ok: true, name, arg: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function applyFilter(value, filter, data, previous) {
|
|
89
|
+
switch (filter.name) {
|
|
90
|
+
case "trim":
|
|
91
|
+
return typeof value === "string" ? value.trim() : value;
|
|
92
|
+
case "lower":
|
|
93
|
+
return typeof value === "string" ? value.toLowerCase() : value;
|
|
94
|
+
case "upper":
|
|
95
|
+
return typeof value === "string" ? value.toUpperCase() : value;
|
|
96
|
+
case "int": {
|
|
97
|
+
const n = Number(value);
|
|
98
|
+
return Number.isFinite(n) ? Math.trunc(n) : value;
|
|
99
|
+
}
|
|
100
|
+
case "round": {
|
|
101
|
+
const n = Number(value);
|
|
102
|
+
if (!Number.isFinite(n)) return value;
|
|
103
|
+
const m = Math.pow(10, filter.arg);
|
|
104
|
+
return Math.round(n * m) / m;
|
|
105
|
+
}
|
|
106
|
+
case "percent": {
|
|
107
|
+
const n = Number(value);
|
|
108
|
+
if (!Number.isFinite(n)) return value;
|
|
109
|
+
return `${Math.round(n * 1000) / 10}%`;
|
|
110
|
+
}
|
|
111
|
+
case "truncate":
|
|
112
|
+
return typeof value === "string" ? value.slice(0, filter.arg) : value;
|
|
113
|
+
case "default":
|
|
114
|
+
return value === undefined || value === null || value === "" ? filter.arg : value;
|
|
115
|
+
case "prefix": {
|
|
116
|
+
// Truthy = non-zero, non-empty. Numbers >= 0 with prefix:"+" should
|
|
117
|
+
// still prepend on positive values; here we treat "truthy" as the
|
|
118
|
+
// value's actual truthiness — agent uses prefix when they want the
|
|
119
|
+
// literal prepended on a non-empty value.
|
|
120
|
+
if (value === undefined || value === null || value === "") return value;
|
|
121
|
+
if (typeof value === "number" && value === 0) return value;
|
|
122
|
+
return `${filter.arg}${value}`;
|
|
123
|
+
}
|
|
124
|
+
case "minus":
|
|
125
|
+
case "plus": {
|
|
126
|
+
const a = Number(value);
|
|
127
|
+
const b = Number(resolvePath(filter.arg, data, previous));
|
|
128
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return value;
|
|
129
|
+
return filter.name === "minus" ? a - b : a + b;
|
|
130
|
+
}
|
|
131
|
+
default:
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stringify(value) {
|
|
137
|
+
if (value === undefined || value === null) return "";
|
|
138
|
+
if (typeof value === "string") return value;
|
|
139
|
+
return String(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// PUBLIC ----
|
|
143
|
+
|
|
144
|
+
export function substituteTemplate(template, data, opts) {
|
|
145
|
+
if (typeof template !== "string") return "";
|
|
146
|
+
const previous = opts && opts.previous ? opts.previous : null;
|
|
147
|
+
return template.replace(/\{\{([^}]+)\}\}/g, (match, exprSrc) => {
|
|
148
|
+
const parts = exprSrc.split("|").map((s) => s.trim());
|
|
149
|
+
const path = parts[0];
|
|
150
|
+
let value = resolvePath(path, data, previous);
|
|
151
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
152
|
+
const f = parseFilter(parts[i]);
|
|
153
|
+
if (!f.ok) return stringify(value); // shouldn't happen if validateTemplate was called
|
|
154
|
+
value = applyFilter(value, f, data, previous);
|
|
155
|
+
}
|
|
156
|
+
return stringify(value);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function validateTemplate(template) {
|
|
161
|
+
if (typeof template !== "string") {
|
|
162
|
+
return { ok: false, code: "refresh_template_invalid", message: "template must be a string" };
|
|
163
|
+
}
|
|
164
|
+
// Detect unclosed braces.
|
|
165
|
+
const openCount = (template.match(/\{\{/g) || []).length;
|
|
166
|
+
const closeCount = (template.match(/\}\}/g) || []).length;
|
|
167
|
+
if (openCount !== closeCount) {
|
|
168
|
+
return { ok: false, code: "refresh_template_invalid", message: "unmatched {{ or }} in template" };
|
|
169
|
+
}
|
|
170
|
+
const re = /\{\{([^}]+)\}\}/g;
|
|
171
|
+
let m;
|
|
172
|
+
while ((m = re.exec(template)) !== null) {
|
|
173
|
+
const parts = m[1].split("|").map((s) => s.trim());
|
|
174
|
+
for (let i = 1; i < parts.length; i += 1) {
|
|
175
|
+
const f = parseFilter(parts[i]);
|
|
176
|
+
if (!f.ok) return f;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { ok: true };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default { substituteTemplate, validateTemplate };
|