@unoverse-platform/studio 0.1.90 → 0.1.91
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/local/plugin.ts +20 -12
- package/package.json +13 -3
- package/vendor/base/src/agent-mcp/discovery.ts +203 -0
- package/vendor/base/src/agent-mcp/handoff.ts +56 -0
- package/vendor/base/src/agent-mcp/index.ts +44 -0
- package/vendor/base/src/agent-mcp/invoke.ts +140 -0
- package/vendor/base/src/agent-mcp/lean.ts +111 -0
- package/vendor/base/src/agent-mcp/memoryContext.ts +104 -0
- package/vendor/base/src/agent-mcp/memoryIngest.ts +88 -0
- package/vendor/base/src/agent-mcp/rowComponents.ts +172 -0
- package/vendor/base/src/agent-mcp/tools.ts +134 -0
- package/vendor/base/src/agent-mcp/types.ts +130 -0
- package/vendor/base/src/auth/auth.ts +278 -0
- package/vendor/base/src/auth/authConfig.ts +73 -0
- package/vendor/base/src/boot.ts +245 -0
- package/vendor/base/src/definitions/definitions.ts +18 -0
- package/vendor/base/src/definitions/dsPackage.ts +45 -0
- package/vendor/base/src/definitions/fsCache.ts +128 -0
- package/vendor/base/src/definitions/index.ts +19 -0
- package/vendor/base/src/definitions/inputs.ts +30 -0
- package/vendor/base/src/definitions/load/compile/component.ts +80 -0
- package/vendor/base/src/definitions/load/compile/index.ts +12 -0
- package/vendor/base/src/definitions/load/compile/sections.ts +132 -0
- package/vendor/base/src/definitions/load/compile/template.ts +153 -0
- package/vendor/base/src/definitions/load/expand/includes.ts +27 -0
- package/vendor/base/src/definitions/load/expand/index.ts +17 -0
- package/vendor/base/src/definitions/load/expand/inherit.ts +169 -0
- package/vendor/base/src/definitions/load/expand/referenced.ts +164 -0
- package/vendor/base/src/definitions/load/expand/refs.ts +106 -0
- package/vendor/base/src/definitions/load/expand/walk.ts +91 -0
- package/vendor/base/src/definitions/load/load.ts +487 -0
- package/vendor/base/src/definitions/model.ts +238 -0
- package/vendor/base/src/definitions/registry/catalog.ts +643 -0
- package/vendor/base/src/definitions/registry/homes.ts +252 -0
- package/vendor/base/src/definitions/theme.ts +383 -0
- package/vendor/base/src/executor.ts +284 -0
- package/vendor/base/src/http/uwsFetchAdapter.ts +260 -0
- package/vendor/base/src/items/baseVersion.ts +96 -0
- package/vendor/base/src/items/collect.ts +199 -0
- package/vendor/base/src/items/fingerprint.ts +34 -0
- package/vendor/base/src/items/publish.ts +198 -0
- package/vendor/base/src/lint/context.mjs +56 -0
- package/vendor/base/src/lint/design/defs.mjs +46 -0
- package/vendor/base/src/lint/design/file.mjs +737 -0
- package/vendor/base/src/lint/design/index.d.mts +30 -0
- package/vendor/base/src/lint/design/index.mjs +996 -0
- package/vendor/base/src/lint/design/tokens.mjs +128 -0
- package/vendor/base/src/lint/design/vocabulary.mjs +181 -0
- package/vendor/base/src/lint/design/walk.mjs +444 -0
- package/vendor/base/src/lint/index.mjs +28 -0
- package/vendor/base/src/lint/nodes/blocks.mjs +63 -0
- package/vendor/base/src/lint/nodes/index.mjs +64 -0
- package/vendor/base/src/lint/nodes/load.mjs +126 -0
- package/vendor/base/src/lint/nodes/node.mjs +764 -0
- package/vendor/base/src/lint/nodes/package.mjs +112 -0
- package/vendor/base/src/lint/nodes/schema.mjs +75 -0
- package/vendor/base/src/logger.ts +33 -0
- package/vendor/base/src/manifests/compose.ts +539 -0
- package/vendor/base/src/manifests/executor/callback.ts +107 -0
- package/vendor/base/src/manifests/executor/context.ts +107 -0
- package/vendor/base/src/manifests/executor/index.ts +47 -0
- package/vendor/base/src/manifests/executor/lookup.ts +21 -0
- package/vendor/base/src/manifests/executor/memory.ts +62 -0
- package/vendor/base/src/manifests/executor/promise.ts +71 -0
- package/vendor/base/src/manifests/executor/refire.ts +34 -0
- package/vendor/base/src/manifests/executor/send.ts +65 -0
- package/vendor/base/src/manifests/executor/session.ts +46 -0
- package/vendor/base/src/manifests/executor/toolBridge.ts +266 -0
- package/vendor/base/src/manifests/index.ts +280 -0
- package/vendor/base/src/manifests/integrity.ts +93 -0
- package/vendor/base/src/manifests/rows.ts +33 -0
- package/vendor/base/src/manifests/runtime/auth/aws.ts +240 -0
- package/vendor/base/src/manifests/runtime/auth/oauth.ts +76 -0
- package/vendor/base/src/manifests/runtime/authorize.ts +159 -0
- package/vendor/base/src/manifests/runtime/channels/final.ts +171 -0
- package/vendor/base/src/manifests/runtime/channels/service.ts +125 -0
- package/vendor/base/src/manifests/runtime/channels/workflow.ts +310 -0
- package/vendor/base/src/manifests/runtime/context.ts +150 -0
- package/vendor/base/src/manifests/runtime/docstore/handlers.ts +547 -0
- package/vendor/base/src/manifests/runtime/docstore/index.ts +135 -0
- package/vendor/base/src/manifests/runtime/docstore/sectionizer.ts +184 -0
- package/vendor/base/src/manifests/runtime/docstore/store.ts +172 -0
- package/vendor/base/src/manifests/runtime/docstore/types.ts +60 -0
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +165 -0
- package/vendor/base/src/manifests/runtime/duplex/session.ts +511 -0
- package/vendor/base/src/manifests/runtime/events.ts +223 -0
- package/vendor/base/src/manifests/runtime/helpers.ts +53 -0
- package/vendor/base/src/manifests/runtime/http/allowedHosts.ts +118 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +637 -0
- package/vendor/base/src/manifests/runtime/http/response.ts +162 -0
- package/vendor/base/src/manifests/runtime/index.ts +57 -0
- package/vendor/base/src/manifests/runtime/loops/chunk.ts +98 -0
- package/vendor/base/src/manifests/runtime/loops/loop.ts +191 -0
- package/vendor/base/src/manifests/runtime/loops/paginate.ts +143 -0
- package/vendor/base/src/manifests/runtime/loops/poll.ts +169 -0
- package/vendor/base/src/manifests/runtime/narrate.ts +53 -0
- package/vendor/base/src/manifests/runtime/state.ts +171 -0
- package/vendor/base/src/manifests/runtime/templating.ts +110 -0
- package/vendor/base/src/manifests/runtime/tools/regionSkills.ts +125 -0
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +542 -0
- package/vendor/base/src/manifests/runtime/usage.ts +87 -0
- package/vendor/base/src/manifests/source.ts +187 -0
- package/vendor/base/src/manifests/watch.ts +67 -0
- package/vendor/base/src/markdown/document.ts +141 -0
- package/vendor/base/src/paths.ts +180 -0
- package/vendor/base/src/platform/audioManager.ts +100 -0
- package/vendor/base/src/platform/clientTransport.ts +65 -0
- package/vendor/base/src/platform/executionContext.ts +154 -0
- package/vendor/base/src/platform/index.ts +10 -0
- package/vendor/base/src/platform/pluginAPI.ts +146 -0
- package/vendor/base/src/platform/redis.ts +54 -0
- package/vendor/base/src/platform/serviceCalls.ts +222 -0
- package/vendor/base/src/pluginBase.ts +248 -0
- package/vendor/base/src/plugins/README.md +57 -0
- package/vendor/base/src/plugins/discovery.ts +87 -0
- package/vendor/base/src/plugins/index.ts +16 -0
- package/vendor/base/src/plugins/loader.ts +179 -0
- package/vendor/base/src/plugins/redis.ts +36 -0
- package/vendor/base/src/plugins/state.ts +53 -0
- package/vendor/base/src/registry.ts +95 -0
- package/vendor/base/src/security/permissions.ts +93 -0
- package/vendor/base/src/security/rules.ts +73 -0
- package/vendor/base/src/template/SafeExpression.ts +556 -0
- package/vendor/base/src/template/StringTemplateResolver.ts +75 -0
- package/vendor/base/src/template/index.ts +22 -0
- package/vendor/base/src/types.ts +50 -0
- package/vendor/base/src/workflow/GetMemoryService.ts +158 -0
- package/vendor/base/src/workflow/PlatformMemoryCore.ts +438 -0
- package/vendor/base/src/workflow/routing.ts +122 -0
- package/vendor/sdk/lib/core/store.ts +26 -6
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POLLING. One call in the manifest, a job on the wire.
|
|
3
|
+
*
|
|
4
|
+
* The third loop, after `paginate` and `chunk`, and the same trade as both: vendors
|
|
5
|
+
* disagree about the mechanism and agree about the shape. Start work, get a handle, ask
|
|
6
|
+
* "done yet?" until it is. What differs is description (where the handle lives, which
|
|
7
|
+
* field says finished, what the status URL looks like); the waiting is computation, so it
|
|
8
|
+
* belongs here and every long-running node inherits it (DECLARATIVE_NODES.md §2).
|
|
9
|
+
*
|
|
10
|
+
* Without this a manifest cannot express a job at all. `run` is an ordered list of
|
|
11
|
+
* DIFFERENT calls, so a manifest can say "start, then check once" but never "check until".
|
|
12
|
+
* Every crawler, render farm, transcription and batch-import API in the world works this
|
|
13
|
+
* way, so the alternative is that all of them stay TypeScript.
|
|
14
|
+
*
|
|
15
|
+
* A polled call's reply is the FINAL status payload, not the start payload. The start
|
|
16
|
+
* reply is a receipt — a job id and nothing else — and handing that back would give the
|
|
17
|
+
* events table a handle where it expected the answer.
|
|
18
|
+
*/
|
|
19
|
+
import type { ComposedNode } from "../../compose.js";
|
|
20
|
+
import type { RunContext } from "../context.js";
|
|
21
|
+
import { evaluate } from "../templating.js";
|
|
22
|
+
import { sendRequest } from "../http/request.js";
|
|
23
|
+
import { readSettled, assertOk } from "../http/response.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A hard ceiling on polls, independent of anything the manifest says.
|
|
27
|
+
*
|
|
28
|
+
* Same reasoning as pagination's page cap: `maxAttempts` is the author's bound, and this
|
|
29
|
+
* is the platform's, because the author's can be wrong. A vendor stuck on "running"
|
|
30
|
+
* forever would otherwise hold a workflow slot until the process died.
|
|
31
|
+
*/
|
|
32
|
+
const HARD_POLL_CAP = 300;
|
|
33
|
+
|
|
34
|
+
/** Longest a single poll may wait between attempts. Guards a typo'd intervalMs. */
|
|
35
|
+
const MAX_INTERVAL_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
export async function fetchPolled(
|
|
38
|
+
node: ComposedNode,
|
|
39
|
+
call: any,
|
|
40
|
+
ctx: RunContext,
|
|
41
|
+
label: string,
|
|
42
|
+
): Promise<any> {
|
|
43
|
+
const p = call.poll;
|
|
44
|
+
const scope = (payload: unknown) => ({ ...ctx, response: payload }) as unknown as Record<string, unknown>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Terminal? Asked of the START reply as well as of every poll.
|
|
48
|
+
*
|
|
49
|
+
* This is not defensiveness, it is the documented behaviour of at least one vendor we
|
|
50
|
+
* migrate: Hyperbrowser's unified /web/fetch may finish INLINE and return the completed
|
|
51
|
+
* result on the POST, with no job id to poll. A manifest that assumed a handle always
|
|
52
|
+
* came back would try to build a status URL out of `undefined` and fail on exactly the
|
|
53
|
+
* fast path it was hoping for.
|
|
54
|
+
*/
|
|
55
|
+
const settled = async (payload: any, where: string): Promise<boolean> => {
|
|
56
|
+
if (p.failed && (await evaluate(p.failed, scope(payload)))) {
|
|
57
|
+
const why = p.message ? await evaluate(p.message, scope(payload)) : "no reason given";
|
|
58
|
+
throw new Error(`${label}: the job failed upstream (${where}) — ${why}`);
|
|
59
|
+
}
|
|
60
|
+
return !!(await evaluate(p.until, scope(payload)));
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// ── Start the job ────────────────────────────────────────────────────────────────
|
|
64
|
+
const startRes = await sendRequest(node, call, ctx, `${label} (start)`);
|
|
65
|
+
const start = await readSettled(startRes, call.transport, call.encoding);
|
|
66
|
+
await assertOk(node, call, start, `${label} (start)`);
|
|
67
|
+
|
|
68
|
+
if (await settled(start, "on the start reply")) return start;
|
|
69
|
+
|
|
70
|
+
// ── Where to ask ─────────────────────────────────────────────────────────────────
|
|
71
|
+
// Resolved ONCE, against the start reply, because that is the only place the job id
|
|
72
|
+
// exists. Re-resolving per attempt against the latest status would work for vendors
|
|
73
|
+
// that echo the id back and break for the ones that do not.
|
|
74
|
+
const url = String((await evaluate(p.url, scope(start))) ?? "");
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A MISSING HANDLE IS NOT AN EMPTY STRING, and checking for empty alone misses it.
|
|
78
|
+
*
|
|
79
|
+
* A status URL is nearly always built by concatenation, so a job id the vendor did not
|
|
80
|
+
* send produces ".../jobs/undefined" — a perfectly non-empty URL that 404s on every one
|
|
81
|
+
* of the next ninety polls and then reports a timeout. The same trap as an auth header
|
|
82
|
+
* going out as the literal "undefined": the string form of nothing looks like a value.
|
|
83
|
+
*
|
|
84
|
+
* So the check is for a path segment that is the WORD undefined or null, which no real
|
|
85
|
+
* job id ever is.
|
|
86
|
+
*/
|
|
87
|
+
if (!url.trim() || /\/(undefined|null)(\/|\?|$)/.test(url))
|
|
88
|
+
throw new Error(
|
|
89
|
+
`${label}: the job did not finish on the start reply, and poll.url did not resolve to a real URL ` +
|
|
90
|
+
`(got ${url.trim() ? `"${url}"` : "nothing"}). The start reply was ${JSON.stringify(start).slice(0, 200)}.`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The STATUS CHECK. Auth, headers, retry, transport and error carry over from the call — a status
|
|
95
|
+
* check is the same conversation with the same vendor — but the start request's method, body and
|
|
96
|
+
* query do NOT: they described starting work.
|
|
97
|
+
*
|
|
98
|
+
* A GET by default, because most vendors put the job id in the path. AWS does not: Textract's
|
|
99
|
+
* `GetDocumentAnalysis` is a POST carrying `{ JobId }`, signed like any other, and every AWS batch
|
|
100
|
+
* job is shaped the same way. Hardcoding GET is what kept that whole family in TypeScript.
|
|
101
|
+
*
|
|
102
|
+
* `poll.body` is an EXPRESSION over the start reply, exactly like `poll.url`, and resolved here
|
|
103
|
+
* ONCE for the same reason: the job id exists only in that first response. Resolving it per
|
|
104
|
+
* attempt against the latest status would work for vendors that echo the id back and quietly
|
|
105
|
+
* break for the ones that do not — the identical trap the url comment above describes.
|
|
106
|
+
*/
|
|
107
|
+
const statusBody = p.body ? await evaluate(p.body, scope(start)) : undefined;
|
|
108
|
+
const statusCall = {
|
|
109
|
+
...call,
|
|
110
|
+
poll: undefined,
|
|
111
|
+
method: p.method ?? "GET",
|
|
112
|
+
url,
|
|
113
|
+
body: statusBody,
|
|
114
|
+
query: undefined,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// RESOLVED, not read: how long to wait is often a dial a person sets, so both may be an
|
|
118
|
+
// expression over config rather than a constant the manifest bakes in. Same treatment as
|
|
119
|
+
// toolExchange's turn budget, and for the same reason.
|
|
120
|
+
const dial = async (v: unknown, fallback: number): Promise<number> => {
|
|
121
|
+
if (v === undefined || v === null) return fallback;
|
|
122
|
+
const n = typeof v === "string" ? Number(await evaluate(v, ctx as unknown as Record<string, unknown>)) : Number(v);
|
|
123
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const interval = Math.min(await dial(p.intervalMs, 2000), MAX_INTERVAL_MS);
|
|
127
|
+
const attempts = Math.min(await dial(p.maxAttempts, 90), HARD_POLL_CAP);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* THE WAIT NARRATES ITSELF — by STATUS CHANGE, with a heartbeat, never per request.
|
|
131
|
+
* A job is minutes of silence in the engine log otherwise, and silence gets read as a
|
|
132
|
+
* hang. Per-attempt logging is the other failure: 300 lines of "still RUNNING" bury the
|
|
133
|
+
* one line that mattered. The query is cut from the url for the same reason it is cut in
|
|
134
|
+
* sendRequest: signatures and filters live there.
|
|
135
|
+
*/
|
|
136
|
+
const startedAt = Date.now();
|
|
137
|
+
const elapsed = () => Math.round((Date.now() - startedAt) / 1000);
|
|
138
|
+
const heartbeat = Math.max(1, Math.round(30_000 / interval));
|
|
139
|
+
let lastStatus: string | undefined;
|
|
140
|
+
console.log(
|
|
141
|
+
`[manifests] ${label}: job started — polling ${url.split("?")[0]} every ${Math.round(interval / 1000)}s (up to ${attempts} attempts)`,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
145
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
146
|
+
|
|
147
|
+
const res = await sendRequest(node, statusCall, ctx, `${label} (poll ${attempt}/${attempts})`);
|
|
148
|
+
const payload = await readSettled(res, call.transport, call.encoding);
|
|
149
|
+
await assertOk(node, call, payload, `${label} (poll ${attempt}/${attempts})`);
|
|
150
|
+
|
|
151
|
+
const status = typeof payload?.status === "string" ? payload.status : undefined;
|
|
152
|
+
if (status !== lastStatus || attempt % heartbeat === 0)
|
|
153
|
+
console.log(`[manifests] ${label}: poll ${attempt}/${attempts} — ${status ?? "(no status field)"} after ${elapsed()}s`);
|
|
154
|
+
lastStatus = status;
|
|
155
|
+
|
|
156
|
+
if (await settled(payload, `poll ${attempt}`)) {
|
|
157
|
+
console.log(`[manifests] ${label}: job finished after ${attempt} polls (${elapsed()}s)`);
|
|
158
|
+
return payload;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Timing out is a real outcome, and the message says how long we actually waited rather
|
|
163
|
+
// than just "timed out": the fix is nearly always a bigger maxAttempts, and a reader
|
|
164
|
+
// cannot judge that without knowing what the current bound bought them.
|
|
165
|
+
throw new Error(
|
|
166
|
+
`${label}: the job was still unfinished after ${attempts} polls ` +
|
|
167
|
+
`(~${Math.round((attempts * interval) / 1000)}s). Raise poll.maxAttempts if this job is legitimately slow.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE NARRATOR — the running commentary a node gives while it works.
|
|
3
|
+
*
|
|
4
|
+
* Its own module, and NOT part of the tool loop, because narration is a property of the NODE
|
|
5
|
+
* rather than of having tools. It used to live inside the loop, which meant a node with no MCP
|
|
6
|
+
* provider wired silently never narrated at all.
|
|
7
|
+
*
|
|
8
|
+
* The manifest describes the whole of it — endpoint, model, instructions, copy. The only part
|
|
9
|
+
* that cannot be data is the timing, which is why there is any code here:
|
|
10
|
+
*
|
|
11
|
+
* a local line at 0ms, because the round trip has a ~1s floor and the first token beats it
|
|
12
|
+
* never awaited, so narration cannot delay the work it describes
|
|
13
|
+
* fall back on any failure, so a missing narrator never fails a turn
|
|
14
|
+
* drop late lines, since one arriving after the answer reads as still working
|
|
15
|
+
*/
|
|
16
|
+
import type { ComposedNode } from "../compose.js";
|
|
17
|
+
import type { RunContext } from "./context.js";
|
|
18
|
+
import { evaluate, render } from "./templating.js";
|
|
19
|
+
import { sendRequest } from "./http/request.js";
|
|
20
|
+
import type { Emitter } from "./events.js";
|
|
21
|
+
|
|
22
|
+
/** Bound for one run. `fire` on each thing worth narrating, `settle` when the answer lands. */
|
|
23
|
+
export function makeNarrator(node: ComposedNode, ctx: RunContext, emitter: Emitter) {
|
|
24
|
+
const n = node.api?.narrate;
|
|
25
|
+
let settled = false;
|
|
26
|
+
let firstLine = false;
|
|
27
|
+
|
|
28
|
+
// Through the table like everything else: the narrator does not know which connector
|
|
29
|
+
// it lands on, only that it produced a line. `from: narrator` in api/events.yaml says.
|
|
30
|
+
const say = (line: unknown) => {
|
|
31
|
+
if (line && !settled) void emitter.narrator(String(line));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const fire = (event: Record<string, unknown>) => {
|
|
35
|
+
if (!n) return;
|
|
36
|
+
if (!firstLine && n.instant?.length) {
|
|
37
|
+
firstLine = true;
|
|
38
|
+
say(n.instant[Math.floor(Math.random() * n.instant.length)]);
|
|
39
|
+
}
|
|
40
|
+
void (async () => {
|
|
41
|
+
try {
|
|
42
|
+
const res = await sendRequest(node, n.request, { ...ctx, event } as any, `${node.type} narrator`);
|
|
43
|
+
say(await evaluate(n.response.line, { response: await res.json() }));
|
|
44
|
+
} catch {
|
|
45
|
+
say(render(n.fallback, ctx));
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return { fire, settle: () => { settled = true; } };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type Narrator = ReturnType<typeof makeNarrator>;
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PLATFORM STATE. The capability that lets a manifest keep something between runs.
|
|
3
|
+
*
|
|
4
|
+
* Every other capability here reaches a vendor over HTTP. This one reaches Redis, through
|
|
5
|
+
* the same `api.getRedisClient()` handle the plugin library already gives a code node, so
|
|
6
|
+
* nothing new is introduced: a manifest simply NAMES an operation the platform already
|
|
7
|
+
* performs (DECLARATIVE_NODES.md §2).
|
|
8
|
+
*
|
|
9
|
+
* Four operations, which is what the retired CRM and Code nodes needed and no more:
|
|
10
|
+
*
|
|
11
|
+
* read the value at a key, or {} when cold
|
|
12
|
+
* merge shallow-merge a patch into it, stamped with updatedAt
|
|
13
|
+
* drain pop up to `max` items off a list, oldest first
|
|
14
|
+
* save put this node's value into the RUN's saved-context hash, so a later template
|
|
15
|
+
* reaches it as saved.<nodeId>
|
|
16
|
+
*
|
|
17
|
+
* `save` NAMES NO KEY, and that is the whole reason it is a separate operation rather than a
|
|
18
|
+
* `merge` the manifest addresses itself. Its key belongs to the ENGINE — NodeExecutionContext
|
|
19
|
+
* reads `saved:<executionId>` when it builds the template scope, and workflowActions deletes it
|
|
20
|
+
* when the run ends — so a manifest writing that key by hand would be guessing at another
|
|
21
|
+
* component's private layout, and would also get the namespace exactly wrong (see below).
|
|
22
|
+
*
|
|
23
|
+
* THE NAMESPACE IS APPLIED HERE, never by the manifest. `REDIS_NAMESPACE` is a deployment
|
|
24
|
+
* fact, and the memory server prefixes its own keys with it. A manifest that had to
|
|
25
|
+
* remember the prefix would eventually forget, both sides would write happily to different
|
|
26
|
+
* keys, and nothing would error: the sync would just silently stop working. So a manifest
|
|
27
|
+
* writes the logical key and cannot get this wrong.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { makeLoopOps, type LoopOps } from "./loops/loop.js";
|
|
31
|
+
|
|
32
|
+
/** What the executor needs from Redis. Injected, so this file touches no platform global. */
|
|
33
|
+
export interface StateStore {
|
|
34
|
+
read(key: string): Promise<Record<string, unknown>>;
|
|
35
|
+
merge(key: string, patch: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
36
|
+
drain(key: string, max: number): Promise<unknown[]>;
|
|
37
|
+
/** The run's saved-context hash. Takes ids, not a key: the layout is the engine's, not ours. */
|
|
38
|
+
save(executionId: string, nodeId: string, value: unknown): Promise<unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* ITERATION BOOKKEEPING, implemented in loops/loop.ts and carried here.
|
|
41
|
+
*
|
|
42
|
+
* It hangs off this interface rather than being a seventh argument threaded through
|
|
43
|
+
* performApi, runCalls and runFinal because it needs exactly the same thing this store
|
|
44
|
+
* needs — the Redis handle — and every one of those call sites already has the store. A
|
|
45
|
+
* parallel parameter would be four signature changes to deliver one dependency twice.
|
|
46
|
+
*/
|
|
47
|
+
loop?: LoopOps;
|
|
48
|
+
/**
|
|
49
|
+
* THE LIVE CLIENT ITSELF, for the one capability the four named operations cannot express:
|
|
50
|
+
* the docstore's WATCH/MULTI transaction (read-check-write as one unit, retried on
|
|
51
|
+
* conflict). Carried here for the same reason `loop` is — every call site that needs it
|
|
52
|
+
* already has the store. Capabilities take it from here; a manifest never sees it.
|
|
53
|
+
*/
|
|
54
|
+
raw?: any;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Bound to a live client. `null` when the platform has no Redis, which is not an error. */
|
|
58
|
+
export function makeStateStore(redis: any, namespace: string | undefined): StateStore | undefined {
|
|
59
|
+
if (!redis) return undefined;
|
|
60
|
+
const ns = namespace ? `${namespace}:` : "";
|
|
61
|
+
const full = (key: string) => `${ns}${key}`;
|
|
62
|
+
|
|
63
|
+
const parse = (raw: string | null): Record<string, unknown> => {
|
|
64
|
+
if (!raw) return {};
|
|
65
|
+
try {
|
|
66
|
+
const v = JSON.parse(raw);
|
|
67
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
68
|
+
} catch {
|
|
69
|
+
return {}; // a corrupt value reads as cold, which is recoverable; throwing is not
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
// Same client, same namespace. See loops/loop.ts for why a loop is one capability rather
|
|
75
|
+
// than the seven raw Redis commands the retired nodes reached for.
|
|
76
|
+
loop: makeLoopOps(redis, namespace),
|
|
77
|
+
|
|
78
|
+
// The handle itself, for the docstore's WATCH/MULTI transaction (see the interface).
|
|
79
|
+
raw: redis,
|
|
80
|
+
|
|
81
|
+
async read(key) {
|
|
82
|
+
return parse(await redis.get(full(key)));
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async merge(key, patch) {
|
|
86
|
+
// Read-modify-write, shallow. The snapshot is written by more than one node and by
|
|
87
|
+
// the memory server, so replacing it wholesale would drop whatever the others put
|
|
88
|
+
// there. No TTL: CRM context stays until something explicitly refreshes it.
|
|
89
|
+
const next = { ...parse(await redis.get(full(key))), ...patch, updatedAt: new Date().toISOString() };
|
|
90
|
+
await redis.set(full(key), JSON.stringify(next));
|
|
91
|
+
return next;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `saved:<executionId>`, one hash field per node — the ENGINE's layout, matched exactly.
|
|
96
|
+
*
|
|
97
|
+
* NOT namespaced, deliberately, and it is the one key here that must not be. Every other key
|
|
98
|
+
* in this file is ours and is shared with the memory server, which prefixes. This one is read
|
|
99
|
+
* by `NodeExecutionContext` and deleted by `workflowActions` with a bare `saved:${executionId}`,
|
|
100
|
+
* so prefixing it would put the value somewhere nothing looks. That failure is invisible: the
|
|
101
|
+
* write succeeds, the toggle appears to work, and `{{ saved.x }}` is quietly empty forever.
|
|
102
|
+
* Hence `full()` is not applied and this comment sits where someone would think to add it.
|
|
103
|
+
*
|
|
104
|
+
* TTL matches the retired node's hour, which is also the loop state's. It is a within-run
|
|
105
|
+
* cache, and the engine deletes the key at the end of a run anyway — the expiry is only there
|
|
106
|
+
* so a run that dies without cleaning up does not leak the key.
|
|
107
|
+
*/
|
|
108
|
+
async save(executionId, nodeId, value) {
|
|
109
|
+
const key = `saved:${executionId}`;
|
|
110
|
+
await redis.hset(key, nodeId, JSON.stringify(value ?? null));
|
|
111
|
+
await redis.expire(key, 3600);
|
|
112
|
+
// `?? null` so `calls.<name>` always HAS a key once this ran. Returning undefined would be
|
|
113
|
+
// indistinguishable from the call being skipped, and that distinction is exactly what the
|
|
114
|
+
// `__saveToContext` flag is derived from.
|
|
115
|
+
return value ?? null;
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async drain(key, max) {
|
|
119
|
+
// LPOP one at a time so dedup is inherent: an item taken is gone, and a crash
|
|
120
|
+
// mid-drain loses at most the one in flight rather than the whole queue.
|
|
121
|
+
const out: unknown[] = [];
|
|
122
|
+
for (let i = 0; i < Math.max(1, max); i++) {
|
|
123
|
+
const raw = await redis.lpop(full(key));
|
|
124
|
+
if (!raw) break;
|
|
125
|
+
try {
|
|
126
|
+
out.push(JSON.parse(raw));
|
|
127
|
+
} catch {
|
|
128
|
+
// A malformed item is skipped rather than retried forever. It is already popped.
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Perform one declared state operation and return what the call's `name` will hold.
|
|
138
|
+
*
|
|
139
|
+
* With no store this returns empty rather than throwing. A universe with no Redis should
|
|
140
|
+
* degrade to "the cache was cold", which every caller already handles, instead of failing
|
|
141
|
+
* a workflow over a cache.
|
|
142
|
+
*/
|
|
143
|
+
export async function performState(
|
|
144
|
+
op: string,
|
|
145
|
+
key: string,
|
|
146
|
+
value: unknown,
|
|
147
|
+
max: number | undefined,
|
|
148
|
+
store: StateStore | undefined,
|
|
149
|
+
/** Only `save` reads this: its key is the run's, not the manifest's. */
|
|
150
|
+
scope: { executionId?: string; nodeId?: string } = {},
|
|
151
|
+
): Promise<unknown> {
|
|
152
|
+
if (!store) return op === "drain" ? [] : op === "save" ? (value ?? null) : {};
|
|
153
|
+
|
|
154
|
+
switch (op) {
|
|
155
|
+
case "read":
|
|
156
|
+
return store.read(key);
|
|
157
|
+
case "merge":
|
|
158
|
+
return store.merge(key, (value as Record<string, unknown>) ?? {});
|
|
159
|
+
case "drain":
|
|
160
|
+
return store.drain(key, max ?? 25);
|
|
161
|
+
case "save":
|
|
162
|
+
// Both ids or nothing. A save keyed by "undefined" would write a hash no run ever reads,
|
|
163
|
+
// and would look identical to a working one from the manifest's side. The retired node
|
|
164
|
+
// defaulted the node id instead, which silently merged two nodes into one field.
|
|
165
|
+
if (!scope.executionId || !scope.nodeId)
|
|
166
|
+
throw new Error(`state "save" needs the run's executionId and nodeId, and this run supplied neither`);
|
|
167
|
+
return store.save(scope.executionId, scope.nodeId, value);
|
|
168
|
+
default:
|
|
169
|
+
throw new Error(`state operation "${op}" is declared in the schema but not implemented in the executor`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Templates and expressions, both borrowed from the platform rather than rebuilt.
|
|
3
|
+
*
|
|
4
|
+
* Part of the manifest runtime (DECLARATIVE_NODES.md §2): the manifest DESCRIBES the
|
|
5
|
+
* service, this half COMPUTES it. Split by concern so each piece stays readable.
|
|
6
|
+
*/
|
|
7
|
+
import type { RunContext } from "./context.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* ORDINARY IMPORTS, at last.
|
|
11
|
+
*
|
|
12
|
+
* These two were reached by SOURCE URL, five directories up into the engine tree, because
|
|
13
|
+
* they lived there and this package could not name them. Such a path is a STRING, so
|
|
14
|
+
* TypeScript could not check either one: moving a folder broke both and every node failed
|
|
15
|
+
* at run time while the build stayed green. Only a test asserting the paths still resolved
|
|
16
|
+
* caught it.
|
|
17
|
+
*
|
|
18
|
+
* They live in this package now (`../../template/`), so they are checked like anything else,
|
|
19
|
+
* and nothing here has to be lazily loaded to work around a path.
|
|
20
|
+
*
|
|
21
|
+
* Neither is a copy, and neither may become one. `SafeExpression` is the sandbox every
|
|
22
|
+
* `return ...` in every manifest runs inside — security by ABSENCE, with no
|
|
23
|
+
* process/require/fetch/eval/new/constructor for an expression to reach. And the Handlebars
|
|
24
|
+
* resolver memoizes compilation, sets noEscape (a prompt with backticks must not arrive
|
|
25
|
+
* HTML-escaped) and registers the helpers a manifest may use (eq, toJSON, filter, contains);
|
|
26
|
+
* calling Handlebars directly here would silently lack all four while the linter went on
|
|
27
|
+
* checking manifests against them.
|
|
28
|
+
*/
|
|
29
|
+
import { evaluateSafeExpression } from "../../template/SafeExpression.js";
|
|
30
|
+
import { resolveStringTemplate } from "../../template/StringTemplateResolver.js";
|
|
31
|
+
import { currentHelpers } from "./helpers.js";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Kept as a no-op rather than deleted. It existed to await a dynamic import, and there is
|
|
35
|
+
* nothing left to await — but every caller and test in the tree awaits it, and removing it
|
|
36
|
+
* would be a wide, purely mechanical change for no behaviour.
|
|
37
|
+
*/
|
|
38
|
+
export async function primeTemplating(): Promise<void> {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A string that is EXACTLY one plain reference, e.g. "{{ config.maxTokens }}".
|
|
42
|
+
*
|
|
43
|
+
* Handlebars always produces a string, so rendering this through it sends "2048"
|
|
44
|
+
* where the vendor demands 2048 and rejects the request. When a template IS the whole
|
|
45
|
+
* value rather than part of a sentence, the value keeps its type.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately only a dotted path: anything with a helper or a block is genuinely
|
|
48
|
+
* text assembly and belongs in Handlebars.
|
|
49
|
+
*/
|
|
50
|
+
const WHOLE_VALUE = /^\s*\{\{\s*([a-zA-Z_$][\w$]*(?:\.[\w$]+)*)\s*\}\}\s*$/;
|
|
51
|
+
|
|
52
|
+
function lookup(path: string, ctx: RunContext): unknown {
|
|
53
|
+
return path.split(".").reduce<any>((a, k) => (a == null ? a : a[k]), ctx as any);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Resolve every {{ }} in a value, recursively. Non-strings pass through. */
|
|
57
|
+
export function render(value: any, ctx: RunContext): any {
|
|
58
|
+
if (typeof value === "string") {
|
|
59
|
+
if (!value.includes("{{")) return value;
|
|
60
|
+
const whole = value.match(WHOLE_VALUE);
|
|
61
|
+
if (whole) return lookup(whole[1], ctx);
|
|
62
|
+
return resolveStringTemplate(value, ctx, console);
|
|
63
|
+
}
|
|
64
|
+
if (Array.isArray(value)) return value.map((v) => render(v, ctx));
|
|
65
|
+
if (value && typeof value === "object") {
|
|
66
|
+
const out: Record<string, any> = {};
|
|
67
|
+
for (const [k, v] of Object.entries(value)) {
|
|
68
|
+
const r = render(v, ctx);
|
|
69
|
+
// A key whose template resolved to nothing is OMITTED rather than sent as an
|
|
70
|
+
// empty string: vendors reject "" where they accept absence.
|
|
71
|
+
if (r !== undefined && r !== "") out[k] = r;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Evaluate a `return ...` expression from a manifest against a scope.
|
|
80
|
+
*
|
|
81
|
+
* Still async, for the same reason primeTemplating still exists: every call site awaits it,
|
|
82
|
+
* and the evaluation itself is synchronous now that the sandbox is imported rather than
|
|
83
|
+
* fetched.
|
|
84
|
+
*/
|
|
85
|
+
export async function evaluate(expr: unknown, scope: Record<string, unknown>): Promise<unknown> {
|
|
86
|
+
/**
|
|
87
|
+
* A LITERAL IS ITSELF. Only a string is code; anything else is data the author wrote out.
|
|
88
|
+
*
|
|
89
|
+
* `getSchema` is what forced this. A method that hands back a constant document had only
|
|
90
|
+
* one way to say so — a `return { ... }` expression — which meant a large object encoded
|
|
91
|
+
* inside a YAML string, with every inner quote escaped and no editor able to check it.
|
|
92
|
+
* Authors write YAML everywhere else in this format; making them write JavaScript-in-a-
|
|
93
|
+
* string to express a constant was the format's problem, not theirs.
|
|
94
|
+
*
|
|
95
|
+
* Nothing is evaluated here, so a literal cannot fail, cannot reference scope, and cannot
|
|
96
|
+
* be a smuggling route: it is returned exactly as parsed.
|
|
97
|
+
*/
|
|
98
|
+
if (typeof expr !== "string") return expr;
|
|
99
|
+
/**
|
|
100
|
+
* The package's named helpers, added HERE rather than at each of the fifty-odd call sites
|
|
101
|
+
* that build a scope. A helper that worked in `returns` but not in an events row would be
|
|
102
|
+
* the sort of inconsistency nobody can predict from reading the manifest.
|
|
103
|
+
*
|
|
104
|
+
* A scope that already carries `helpers` wins, so a caller can still pass its own — and so
|
|
105
|
+
* this cannot silently shadow something a future scope defines.
|
|
106
|
+
*/
|
|
107
|
+
const helpers = currentHelpers();
|
|
108
|
+
const withBag = helpers && !("helpers" in scope) ? { ...scope, helpers } : scope;
|
|
109
|
+
return evaluateSafeExpression(expr.replace(/^return\s+/, ""), withBag);
|
|
110
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE SKILLS IN FORCE, held the same way whichever door the conversation came through.
|
|
3
|
+
*
|
|
4
|
+
* A region-attached skill reaches an Agent by POSITION rather than by the model choosing to
|
|
5
|
+
* read one, and two runtimes drive that: `runToolLoop` for a request/response vendor, and
|
|
6
|
+
* `duplex/session` for a socket. Both absorb the same `regionSkills` from the same search
|
|
7
|
+
* result, and both then have to answer the same three questions — what changed, what does
|
|
8
|
+
* the model get told, and what goes on the timeline.
|
|
9
|
+
*
|
|
10
|
+
* ONE COPY, because the answers are properties of the FEATURE and not of the transport. The
|
|
11
|
+
* loop had all of this inline and the socket had none of it, which is exactly how voice came
|
|
12
|
+
* to absorb region skills correctly and then throw them away: nothing shared meant nothing
|
|
13
|
+
* obliged the second runtime to do anything with them.
|
|
14
|
+
*
|
|
15
|
+
* What genuinely differs between the two stays with them: the loop rebuilds its prompt every
|
|
16
|
+
* turn and so needs no notification, while a socket sent its instructions once at the
|
|
17
|
+
* handshake and must re-send them when this reports a change.
|
|
18
|
+
*/
|
|
19
|
+
import type { RunContext } from "../context.js";
|
|
20
|
+
// `recordToolTrace` lives in toolloop.ts and both runtimes already import it from there, so
|
|
21
|
+
// this takes it from the same place rather than moving a working export and touching every
|
|
22
|
+
// caller. The cycle is function-level and resolves at call time.
|
|
23
|
+
import { recordToolTrace } from "./toolloop.js";
|
|
24
|
+
|
|
25
|
+
/** One skill in force. Shape as it arrives from the engine's `regionSkills`. */
|
|
26
|
+
export interface InForceSkill {
|
|
27
|
+
name: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
instructions?: string;
|
|
30
|
+
regionId?: string;
|
|
31
|
+
regionName?: string | null;
|
|
32
|
+
/** How long the engine spent resolving it, for the timeline bar. */
|
|
33
|
+
ms?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What the model gets told, appended to whatever the manifest calls a system prompt.
|
|
38
|
+
*
|
|
39
|
+
* Empty until something is in force, so a node that never loads one sends exactly what it
|
|
40
|
+
* always did.
|
|
41
|
+
*
|
|
42
|
+
* GENERAL FIRST, SPECIFIC LAST — the engine returns them parent region before child, and
|
|
43
|
+
* insertion order is preserved, so the more specific instructions come last.
|
|
44
|
+
*
|
|
45
|
+
* THEY ADD TO THE ROLE, THEY DO NOT REPLACE IT. A skill is know-how for a situation, not a
|
|
46
|
+
* new identity: an Agent that reaches this part of the map is still the same Agent, doing
|
|
47
|
+
* the same job, now with guidance for where it is standing. Wording this as an override
|
|
48
|
+
* invites the model to drop its role prompt the moment a skill loads, which is the opposite
|
|
49
|
+
* of what an attachment is for.
|
|
50
|
+
*/
|
|
51
|
+
export function renderSkillsText(skills: Map<string, InForceSkill>): string {
|
|
52
|
+
if (!skills.size) return "";
|
|
53
|
+
const bodies = [...skills.values()].map((s) => `## ${s.title || s.name}\n\n${s.instructions}`);
|
|
54
|
+
return (
|
|
55
|
+
"\n\n---\n\nThe following skills apply to this part of the conversation. They ADD to " +
|
|
56
|
+
"your role and instructions above rather than replacing them: keep doing your job, " +
|
|
57
|
+
"and let these guide how you do it here. Where two skills overlap, the later one is " +
|
|
58
|
+
"the more specific.\n\n" +
|
|
59
|
+
bodies.join("\n\n")
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fold a search's `regionSkills` into the held set, and say whether anything moved.
|
|
65
|
+
*
|
|
66
|
+
* WHAT IS IN FORCE IS WHERE YOU ARE. The resolved list REPLACES the held one rather than
|
|
67
|
+
* adding to it, because a skill is attached to a region and a conversation that has moved on
|
|
68
|
+
* is no longer in that region. Accumulating would leave someone being coached on
|
|
69
|
+
* qualifications long after they had wandered into complaints.
|
|
70
|
+
*
|
|
71
|
+
* `null` means the reply said nothing about position: leave it alone. `[]` means it was
|
|
72
|
+
* placed and nothing applies there, which includes unassigned ground, and that CLEARS.
|
|
73
|
+
*
|
|
74
|
+
* A skill already held is not reloaded: it stays in the map, keeps its place in the order,
|
|
75
|
+
* and raises no event. Only what actually changed is news.
|
|
76
|
+
*/
|
|
77
|
+
export function applyInForce(
|
|
78
|
+
held: Map<string, InForceSkill>,
|
|
79
|
+
inForce: InForceSkill[] | null | undefined,
|
|
80
|
+
node: { type: string },
|
|
81
|
+
ctx: RunContext,
|
|
82
|
+
): { changed: boolean; added: InForceSkill[]; dropped: string[] } {
|
|
83
|
+
if (!inForce) return { changed: false, added: [], dropped: [] };
|
|
84
|
+
|
|
85
|
+
const before = new Set(held.keys());
|
|
86
|
+
const next = new Map<string, InForceSkill>();
|
|
87
|
+
for (const s of inForce) next.set(s.name, held.get(s.name) ?? s);
|
|
88
|
+
const added = inForce.filter((s) => !before.has(s.name));
|
|
89
|
+
const dropped = [...before].filter((n) => !next.has(n));
|
|
90
|
+
if (!added.length && !dropped.length) return { changed: false, added: [], dropped: [] };
|
|
91
|
+
|
|
92
|
+
held.clear();
|
|
93
|
+
for (const [k, v] of next) held.set(k, v);
|
|
94
|
+
|
|
95
|
+
for (const s of added) {
|
|
96
|
+
console.log(
|
|
97
|
+
`[manifests] ${node.type}: skill in force "${s.name}" from region ${s.regionName ?? s.regionId ?? "?"}`,
|
|
98
|
+
);
|
|
99
|
+
/**
|
|
100
|
+
* ON THE TIMELINE, through the SAME publisher every tool call uses.
|
|
101
|
+
*
|
|
102
|
+
* A skill coming into force changes what the model is working to, so it belongs on the
|
|
103
|
+
* execution timeline beside the search that caused it. Its OWN KIND, not a name with a
|
|
104
|
+
* prefix on it: the trace lands as node_type `skill_load` and the viewer draws it as a
|
|
105
|
+
* skill because it was told so.
|
|
106
|
+
*
|
|
107
|
+
* THE DURATION COMES FROM THE ENGINE (`ms`), not from here. A runtime only learns a
|
|
108
|
+
* skill exists once the search returns, so timing it locally would measure the gap
|
|
109
|
+
* between hearing and recording — zero — and draw every skill as an instantaneous step.
|
|
110
|
+
*/
|
|
111
|
+
recordToolTrace(
|
|
112
|
+
node as any,
|
|
113
|
+
ctx,
|
|
114
|
+
s.name,
|
|
115
|
+
{ region: s.regionName ?? s.regionId ?? null },
|
|
116
|
+
{ title: s.title ?? s.name, instructions: s.instructions ?? "" },
|
|
117
|
+
Date.now() - (Number(s.ms) || 0),
|
|
118
|
+
true,
|
|
119
|
+
"skill",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
for (const n of dropped) console.log(`[manifests] ${node.type}: skill cleared "${n}" (left its region)`);
|
|
123
|
+
|
|
124
|
+
return { changed: true, added, dropped };
|
|
125
|
+
}
|