@unoverse-platform/studio 0.1.115 → 0.1.116
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/catalog.ts +3 -0
- package/local/plugin.ts +12 -9
- package/package.json +1 -1
- package/screens/AgentChain.tsx +216 -0
- package/screens/AgentsView.tsx +45 -6
- package/screens/SkillsView.tsx +21 -3
- package/screens/index.ts +3 -0
- package/vendor/base/src/agent-mcp/discovery.ts +4 -1
- package/vendor/base/src/agent-mcp/invoke.ts +155 -25
- package/vendor/base/src/agent-mcp/tools.ts +17 -10
- package/vendor/base/src/agent-mcp/types.ts +8 -0
- package/vendor/base/src/auth/agentSwitch.ts +52 -0
- package/vendor/base/src/auth/agentToken.ts +142 -0
- package/vendor/base/src/auth/auth.ts +7 -0
- package/vendor/base/src/definitions/load/load.ts +6 -1
- package/vendor/base/src/definitions/registry/agentFolders.ts +113 -0
- package/vendor/base/src/definitions/registry/objects.ts +16 -0
- package/vendor/base/src/definitions/registry/pipelines.ts +13 -5
- package/vendor/base/src/definitions/registry/taskStarts.ts +76 -0
- package/vendor/base/src/definitions/registry/taskUri.ts +67 -0
- package/vendor/base/src/lint/design/index.mjs +10 -2
- package/vendor/base/src/lint/objects/index.d.mts +1 -0
- package/vendor/base/src/lint/objects/index.mjs +120 -2
- package/vendor/base/src/lint/objects/rules.mjs +16 -5
- package/vendor/base/src/manifests/executor/toolBridge.ts +10 -3
- package/vendor/base/src/manifests/marketplaceMcp.ts +20 -0
- package/vendor/base/src/manifests/runtime/http/request.ts +4 -1
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +58 -23
- package/vendor/base/src/manifests/runtime/usage.ts +13 -5
- package/vendor/base/src/platform/agentBudget.ts +65 -0
- package/vendor/base/src/platform/agentCalendar.ts +87 -0
- package/vendor/base/src/platform/taskChanged.ts +25 -0
- package/vendor/base/src/platform/tokenCost.ts +395 -0
- package/vendor/base/src/security/permissions.ts +10 -2
- package/vendor/sdk/appEngine.ts +42 -1
- package/vendor/sdk/lib/dom/markdown.ts +6 -2
- package/vendor/sdk/pageInterfaces.ts +109 -8
|
@@ -32,8 +32,11 @@ import { makeUsageCollector } from "../usage.js";
|
|
|
32
32
|
export interface ToolBridge {
|
|
33
33
|
/** Core tools from the provider's getSchema, as { name, description, parameters }. */
|
|
34
34
|
discover(): Promise<any[]>;
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Execute one call; returns the raw result content. `onStatus` hears what a called TASK is
|
|
37
|
+
* doing each time its status changes (docs/mcp/MCP_TASKS.md § 5, "what reaches the model").
|
|
38
|
+
*/
|
|
39
|
+
call(name: string, args: any, hooks?: { onStatus?: (statusMessage: string) => void }): Promise<string>;
|
|
37
40
|
/**
|
|
38
41
|
* Absorb one tool reply: unlock + shell-open any apps it discovered, and hand back the
|
|
39
42
|
* MODEL-FACING form of it plus any tools to register.
|
|
@@ -92,7 +95,7 @@ export function recordToolTrace(
|
|
|
92
95
|
* on the same timeline but is not a tool the model chose, so it is told apart by a stated
|
|
93
96
|
* kind rather than by the viewer sniffing its name.
|
|
94
97
|
*/
|
|
95
|
-
kind: "tool" | "skill" | "task" = "tool",
|
|
98
|
+
kind: "tool" | "skill" | "task" | "turn" = "tool",
|
|
96
99
|
): void {
|
|
97
100
|
recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName, args, result, startTime, success, kind, who: node.type });
|
|
98
101
|
}
|
|
@@ -111,7 +114,7 @@ export function recordCall(call: {
|
|
|
111
114
|
result: unknown;
|
|
112
115
|
startTime: number;
|
|
113
116
|
success: boolean;
|
|
114
|
-
kind?: "tool" | "skill" | "task";
|
|
117
|
+
kind?: "tool" | "skill" | "task" | "turn";
|
|
115
118
|
who: string;
|
|
116
119
|
}): void {
|
|
117
120
|
const { executionId, parentNodeId, toolName, args, result, startTime, success, kind = "tool" } = call;
|
|
@@ -245,8 +248,8 @@ export async function runToolLoop(
|
|
|
245
248
|
* written, or the held set would keep a skill the conversation has walked away from. */
|
|
246
249
|
let skillsChanged = false;
|
|
247
250
|
|
|
248
|
-
// Token usage
|
|
249
|
-
//
|
|
251
|
+
// Token usage, posted after EACH turn (each turn is its own model call with its own usage
|
|
252
|
+
// block), so a run's spend is on record while it runs, and once more when the loop settles.
|
|
250
253
|
const usage = makeUsageCollector(node, ctx);
|
|
251
254
|
|
|
252
255
|
for (turn = 1; turn <= maxTurns; turn++) {
|
|
@@ -275,6 +278,9 @@ export async function runToolLoop(
|
|
|
275
278
|
// would send each result twice — once as a message and once in a field the vendor ignores.
|
|
276
279
|
if (results && !te.transcript) extra[te.resultsInto ?? "input"] = results;
|
|
277
280
|
|
|
281
|
+
// WHEN THE TURN STARTED, for its own row on the timeline (below): without it the gap between
|
|
282
|
+
// two rounds of tool calls could not be told apart into model time and everything else.
|
|
283
|
+
const turnStart = Date.now();
|
|
278
284
|
const res = await sendRequest(node, withExtra(modelCall, extra), scoped, `${node.type} (turn ${turn})`);
|
|
279
285
|
|
|
280
286
|
// Two readers of one stream: the events table decides what leaves the node, and the
|
|
@@ -291,6 +297,12 @@ export async function runToolLoop(
|
|
|
291
297
|
* Chat Completions), and accumulated because it arrives a token at a time.
|
|
292
298
|
*/
|
|
293
299
|
let spoken = "";
|
|
300
|
+
/**
|
|
301
|
+
* The assistant's OWN REASONING this turn, for a vendor that keeps it only when it is sent back
|
|
302
|
+
* (GLM's preserved thinking). Without it the model rebuilds its whole plan every turn. Read and
|
|
303
|
+
* accumulated exactly as `spoken` is; the manifest says where it arrives and where it goes back.
|
|
304
|
+
*/
|
|
305
|
+
let thought = "";
|
|
294
306
|
await readSse(res, modelCall.terminator, async (payload) => {
|
|
295
307
|
await assertOk(node, modelCall, payload, `${node.type} (turn ${turn})`);
|
|
296
308
|
usage.see(payload);
|
|
@@ -299,6 +311,10 @@ export async function runToolLoop(
|
|
|
299
311
|
const piece = await evaluate(te.transcript.text, { response: payload });
|
|
300
312
|
if (piece) spoken += String(piece);
|
|
301
313
|
}
|
|
314
|
+
if (te.transcript?.thinking) {
|
|
315
|
+
const piece = await evaluate(te.transcript.thinking, { response: payload });
|
|
316
|
+
if (piece) thought += String(piece);
|
|
317
|
+
}
|
|
302
318
|
// `match` is OPTIONAL. It selects by event TYPE, which only works for a vendor whose SSE
|
|
303
319
|
// events carry one — the Responses API does. A Chat Completions chunk has no `type` at all, so
|
|
304
320
|
// with no match declared every payload is considered and `when` does the filtering.
|
|
@@ -306,6 +322,7 @@ export async function runToolLoop(
|
|
|
306
322
|
if (te.continuity && payload.type === te.continuity.match) chainPayload = payload;
|
|
307
323
|
});
|
|
308
324
|
if (chainPayload) chainId = String(await evaluate(te.continuity.from, { response: chainPayload }));
|
|
325
|
+
usage.save();
|
|
309
326
|
|
|
310
327
|
// THE TURN IS OVER, SO NOTHING MORE IS COMING TO CARRY A THROTTLE OUT.
|
|
311
328
|
//
|
|
@@ -321,6 +338,9 @@ export async function runToolLoop(
|
|
|
321
338
|
emitter.flush();
|
|
322
339
|
|
|
323
340
|
const calls = await readCalls(te, raw);
|
|
341
|
+
// EACH MODEL TURN ON THE TIMELINE, beside the calls it asked for: how long the model took,
|
|
342
|
+
// what it asked for and how much it wrote. Recorded once the turn has streamed in full.
|
|
343
|
+
recordCall({ executionId: (ctx as any)?.scope?.executionId, parentNodeId: (ctx as any)?.scope?.nodeId, toolName: `turn ${turn}`, args: { toolsOffered: live.length }, result: { asked: calls.map((c) => c.name), wroteChars: spoken.length }, startTime: turnStart, success: true, kind: "turn", who: node.type });
|
|
324
344
|
|
|
325
345
|
// Answered rather than called. Done.
|
|
326
346
|
if (!calls.length) break;
|
|
@@ -337,6 +357,8 @@ export async function runToolLoop(
|
|
|
337
357
|
const absorbed: string[] = [];
|
|
338
358
|
let stuck = false;
|
|
339
359
|
|
|
360
|
+
// Which calls run: every one before the first repeated too often, in the order asked.
|
|
361
|
+
const toRun: { call: (typeof calls)[number]; args: any }[] = [];
|
|
340
362
|
for (const call of calls) {
|
|
341
363
|
const signature = `${call.name}:${call.arguments}`;
|
|
342
364
|
const count = (seen.get(signature) ?? 0) + 1;
|
|
@@ -346,25 +368,38 @@ export async function runToolLoop(
|
|
|
346
368
|
stuck = true;
|
|
347
369
|
break;
|
|
348
370
|
}
|
|
349
|
-
|
|
350
371
|
const args = JSON.parse(call.arguments || "{}");
|
|
351
372
|
narrate({ kind: "toolCall", toolName: call.name, args });
|
|
373
|
+
toRun.push({ call, args });
|
|
374
|
+
}
|
|
352
375
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
}
|
|
376
|
+
/**
|
|
377
|
+
* THE CALLS OF ONE TURN RUN TOGETHER (UNO-190; docs/mcp/MCP_TASKS.md § 5, Orchestrated).
|
|
378
|
+
* A model that asks for several tools at once wants them side by side: twelve searches
|
|
379
|
+
* fanned out as tasks take as long as the slowest, not the sum. Everything after the call
|
|
380
|
+
* (recording, absorbing, skills in force, minted tools) still goes in the order asked.
|
|
381
|
+
*
|
|
382
|
+
* THE TIMELINE BAR is the TOOL's to record, never the loop's (owner, 2026-10-01;
|
|
383
|
+
* packages/docs/nodes/mcp-services.md, "Who records a call"). Every tool records its
|
|
384
|
+
* own call wherever it runs (a node's method, a row's function, a platform tool), with
|
|
385
|
+
* `success` false on a failure, so a call reads the same from an Agent, a voice session
|
|
386
|
+
* or an outside host. The bar once vanished the day `openai` migrated, because it lived
|
|
387
|
+
* in each Agent family; it lives with the tool now.
|
|
388
|
+
*/
|
|
389
|
+
const contents = await Promise.all(
|
|
390
|
+
toRun.map(({ call, args }) =>
|
|
391
|
+
Promise.resolve()
|
|
392
|
+
// A TASK THAT RUNS FOR MINUTES SAYS WHAT IT IS DOING: each new status is narrated,
|
|
393
|
+
// so the caller (a person, a voice) hears progress before the result arrives.
|
|
394
|
+
.then(() => bridge.call(call.name, args, { onStatus: (status) => narrate({ kind: "taskStatus", toolName: call.name, args, status }) }))
|
|
395
|
+
// A failed tool is information for the model, not a crash: it can try another.
|
|
396
|
+
.catch((err: any) => JSON.stringify({ error: err?.message ?? String(err) })),
|
|
397
|
+
),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
for (let i = 0; i < toRun.length; i++) {
|
|
401
|
+
const { call, args } = toRun[i];
|
|
402
|
+
const content = contents[i];
|
|
368
403
|
exchanges.push({ name: call.name, resultContent: content });
|
|
369
404
|
// The RESULT, and only now that it exists. It is never in the HTTP stream: our loop
|
|
370
405
|
// produced it, which is why it cannot be a `from: response` row. PARSED for the
|
|
@@ -432,7 +467,7 @@ export async function runToolLoop(
|
|
|
432
467
|
* vendor's message shape — `te.result` produced them — so they go in as they are.
|
|
433
468
|
*/
|
|
434
469
|
if (te.transcript) {
|
|
435
|
-
if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken }));
|
|
470
|
+
if (te.transcript.assistant) transcript.push(await evaluate(te.transcript.assistant, { calls, text: spoken, thinking: thought }));
|
|
436
471
|
transcript.push(...results);
|
|
437
472
|
}
|
|
438
473
|
}
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* Sniffing those three is a wire-format rule, not a per-node rule — no manifest changes.
|
|
16
16
|
*
|
|
17
17
|
* A run may hold SEVERAL model calls (the tool loop's turns), each with its own usage
|
|
18
|
-
* block; the collector SUMS numeric fields across
|
|
19
|
-
*
|
|
18
|
+
* block; the collector SUMS numeric fields across what it saw since its last save, and the
|
|
19
|
+
* rows for one execution add up to what it cost. Fire-and-forget on save, same contract as the MCP trace:
|
|
20
20
|
* observability must never slow a run or fail it.
|
|
21
21
|
*/
|
|
22
22
|
import type { ComposedNode } from "../compose.js";
|
|
@@ -38,7 +38,12 @@ export interface UsageCollector {
|
|
|
38
38
|
* the length. A vendor that reports running totals (GPT-Live) would otherwise be summed.
|
|
39
39
|
*/
|
|
40
40
|
set(field: string, value: number): void;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* Post what was seen SINCE THE LAST SAVE to the engine, then start again from nothing. Called
|
|
43
|
+
* after each turn of a tool loop, so spend is on record while the run is still going: a budget
|
|
44
|
+
* read mid-run (an Agent's, AGENTS.md § 9) saw nothing until the loop settled. Nothing new, or no
|
|
45
|
+
* execution scope → no-op.
|
|
46
|
+
*/
|
|
42
47
|
save(): void;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -61,7 +66,7 @@ function addInto(totals: Record<string, any>, block: Record<string, unknown>): v
|
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCollector {
|
|
64
|
-
|
|
69
|
+
let totals: Record<string, number | Record<string, number>> = {};
|
|
65
70
|
let seen = false;
|
|
66
71
|
let model: string | undefined;
|
|
67
72
|
|
|
@@ -80,6 +85,9 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
|
|
|
80
85
|
},
|
|
81
86
|
save() {
|
|
82
87
|
if (!seen) return;
|
|
88
|
+
const spent = totals;
|
|
89
|
+
totals = {};
|
|
90
|
+
seen = false;
|
|
83
91
|
const { workflowId, executionId, nodeId } = ctx.scope ?? {};
|
|
84
92
|
// Node-test / headless: no execution to attach to (same rule as the MCP trace).
|
|
85
93
|
if (!workflowId || !executionId || !nodeId) return;
|
|
@@ -89,7 +97,7 @@ export function makeUsageCollector(node: ComposedNode, ctx: RunContext): UsageCo
|
|
|
89
97
|
nodeId,
|
|
90
98
|
nodeType: node.type,
|
|
91
99
|
model: model ?? (ctx.config as any)?.model ?? "unknown",
|
|
92
|
-
usage:
|
|
100
|
+
usage: spent,
|
|
93
101
|
timestamp: new Date().toISOString(),
|
|
94
102
|
});
|
|
95
103
|
},
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN AGENT'S BUDGET (docs/architecture/signal/AGENTS.md § 9, § 9a; UNO-222).
|
|
3
|
+
*
|
|
4
|
+
* Counted, not trusted: what an Agent spent is the platform's own usage rows (`token_usage`) for
|
|
5
|
+
* everything run as the Agent (its own thinking and every task it called), priced by the one table
|
|
6
|
+
* (`tokenCost.ts`). Checked BEFORE every model call and every task, never after:
|
|
7
|
+
*
|
|
8
|
+
* under 80% of a limit go on
|
|
9
|
+
* 80% or more go on, its owner is told once
|
|
10
|
+
* 100% start nothing new
|
|
11
|
+
*
|
|
12
|
+
* Pure: the rows and the limits come in.
|
|
13
|
+
*/
|
|
14
|
+
import { parseTokenUsage, calculateTokenCost } from "./tokenCost.js";
|
|
15
|
+
|
|
16
|
+
/** "20 USD", "20", "$20" → 20. Null when it is not an amount in US dollars. */
|
|
17
|
+
export function dollars(amount: string | undefined): number | null {
|
|
18
|
+
if (!amount) return null;
|
|
19
|
+
const m = /^\s*\$?\s*(\d+(?:\.\d+)?)\s*(USD)?\s*$/i.exec(amount);
|
|
20
|
+
return m ? Number(m[1]) : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One usage row as the platform keeps it. */
|
|
24
|
+
export interface UsageRow {
|
|
25
|
+
model: string | null;
|
|
26
|
+
usage: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What these rows cost, in US dollars. */
|
|
30
|
+
export function spent(rows: UsageRow[]): number {
|
|
31
|
+
let total = 0;
|
|
32
|
+
for (const row of rows) {
|
|
33
|
+
if (!row.model) continue;
|
|
34
|
+
total += Number(calculateTokenCost(row.model, parseTokenUsage(row.model, row.usage)));
|
|
35
|
+
}
|
|
36
|
+
return Math.round(total * 10000) / 10000;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BudgetLimits {
|
|
40
|
+
perDay?: string;
|
|
41
|
+
perRun?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type BudgetCheck =
|
|
45
|
+
| { go: true; near?: "day" | "run"; message?: string }
|
|
46
|
+
| { go: false; limit: "day" | "run" | "none"; message: string };
|
|
47
|
+
|
|
48
|
+
/** May the Agent start one more model call or task? */
|
|
49
|
+
export function checkBudget(limits: BudgetLimits, used: { today: number; run: number }): BudgetCheck {
|
|
50
|
+
const day = dollars(limits.perDay);
|
|
51
|
+
if (day === null) return { go: false, limit: "none", message: "No daily budget: an Agent with no budget.perDay never runs" };
|
|
52
|
+
const run = dollars(limits.perRun);
|
|
53
|
+
if (used.today >= day) return { go: false, limit: "day", message: `Today's budget is spent: $${used.today.toFixed(2)} of $${day.toFixed(2)}. Carries on tomorrow.` };
|
|
54
|
+
if (run !== null && used.run >= run) return { go: false, limit: "run", message: `This run's budget is spent: $${used.run.toFixed(2)} of $${run.toFixed(2)}.` };
|
|
55
|
+
if (used.today >= day * 0.8) return { go: true, near: "day", message: `$${used.today.toFixed(2)} of today's $${day.toFixed(2)} spent` };
|
|
56
|
+
if (run !== null && used.run >= run * 0.8) return { go: true, near: "run", message: `$${used.run.toFixed(2)} of this run's $${run.toFixed(2)} spent` };
|
|
57
|
+
return { go: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Midnight today in the environment's own time zone, where an Agent's day starts. */
|
|
61
|
+
export function startOfToday(now: Date = new Date()): Date {
|
|
62
|
+
const d = new Date(now);
|
|
63
|
+
d.setHours(0, 0, 0, 0);
|
|
64
|
+
return d;
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AN AGENT'S DAY IS ITS CALENDAR (docs/architecture/signal/AGENTS.md § 5; UNO-207).
|
|
3
|
+
*
|
|
4
|
+
* A person drops slots into the Agent's calendar: the title names the slot, the description is
|
|
5
|
+
* what to do. Every `checkIn` the calendar is read; a slot that started since the last look, or is
|
|
6
|
+
* under way now, wakes the Agent, once, with the slot as its instruction: its work is a task from the
|
|
7
|
+
* moment it is on the calendar, never something to wait for. These are the rules; the engine's check-in
|
|
8
|
+
* (`engine/src/agentKeys/checkIn.ts`) reads the calendar and wakes it.
|
|
9
|
+
*
|
|
10
|
+
* Only people in its org give it work (§ 7b): a slot made by the mailbox itself, or by someone at
|
|
11
|
+
* the same company domain. A public mail domain (gmail.com and the like) is nobody's company, so
|
|
12
|
+
* there only the mailbox's own slots count. An outside invitation is never an instruction.
|
|
13
|
+
*
|
|
14
|
+
* A slot names the Agent (owner, 2026-10-10: "start with Bob:"): its title begins with the Agent's
|
|
15
|
+
* name and a colon, "Bob: Morning sweep". Every other event in the calendar is a person's own
|
|
16
|
+
* meeting and never wakes it, which matters while it reads a person's calendar rather than its own.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The fields of an event these rules read. */
|
|
20
|
+
export interface SlotEvent {
|
|
21
|
+
id: string;
|
|
22
|
+
title: string;
|
|
23
|
+
description: string;
|
|
24
|
+
start: string;
|
|
25
|
+
/** When it ends; empty for none. A slot under way still wakes an Agent that has not worked it. */
|
|
26
|
+
end?: string | null;
|
|
27
|
+
organizer: { address: string } | null;
|
|
28
|
+
creator: { address: string } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** How often its calendar is read when its file says nothing. */
|
|
32
|
+
export const DEFAULT_CHECK_IN_MS = 15 * 60_000;
|
|
33
|
+
|
|
34
|
+
/** "15m", "1h", "30s" → milliseconds; anything else, the default. Never under a minute. */
|
|
35
|
+
export function checkInEvery(checkIn: string | undefined): number {
|
|
36
|
+
const m = /^\s*(\d+)\s*(s|m|h)\s*$/i.exec(checkIn ?? "");
|
|
37
|
+
if (!m) return DEFAULT_CHECK_IN_MS;
|
|
38
|
+
const ms = Number(m[1]) * { s: 1000, m: 60_000, h: 3_600_000 }[m[2].toLowerCase() as "s" | "m" | "h"];
|
|
39
|
+
return Math.max(ms, 60_000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PUBLIC_DOMAINS = new Set(["gmail.com", "googlemail.com", "outlook.com", "hotmail.com", "live.com", "msn.com", "yahoo.com", "icloud.com", "me.com", "proton.me", "protonmail.com", "aol.com"]);
|
|
43
|
+
const domainOf = (address: string) => address.slice(address.lastIndexOf("@") + 1).toLowerCase();
|
|
44
|
+
|
|
45
|
+
/** Was this slot made by someone in the Agent's org? */
|
|
46
|
+
export function fromOrg(event: SlotEvent, mailbox: string): boolean {
|
|
47
|
+
const maker = (event.creator ?? event.organizer)?.address?.toLowerCase();
|
|
48
|
+
const own = mailbox.trim().toLowerCase();
|
|
49
|
+
if (!maker) return false;
|
|
50
|
+
if (maker === own) return true;
|
|
51
|
+
const domain = domainOf(own);
|
|
52
|
+
return !PUBLIC_DOMAINS.has(domain) && domainOf(maker) === domain;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** "Bob: Morning sweep" for Bob → "Morning sweep"; null when the slot is not addressed to it. */
|
|
56
|
+
export function addressedTo(title: string, agentName: string): string | null {
|
|
57
|
+
const name = agentName.trim().toLowerCase();
|
|
58
|
+
const t = title.trim();
|
|
59
|
+
if (!name || t.slice(0, name.length + 1).toLowerCase() !== `${name}:`) return null;
|
|
60
|
+
return t.slice(name.length + 1).trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The slots to work now, earliest first: started after `since` and by `now`, or started earlier
|
|
64
|
+
* and still under way. Whether one was already worked is the run record's (`slotKey`). */
|
|
65
|
+
export function slotsDue<T extends SlotEvent>(events: T[], since: Date, now: Date): T[] {
|
|
66
|
+
return events
|
|
67
|
+
.filter((e) => {
|
|
68
|
+
const at = Date.parse(e.start);
|
|
69
|
+
if (at > now.getTime()) return false;
|
|
70
|
+
if (at > since.getTime()) return true;
|
|
71
|
+
const end = e.end ? Date.parse(e.end) : NaN;
|
|
72
|
+
return end > now.getTime();
|
|
73
|
+
})
|
|
74
|
+
.sort((a, b) => Date.parse(a.start) - Date.parse(b.start));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One slot, once: a recurring slot's occurrences differ by start. What its run is recorded as. */
|
|
78
|
+
export function slotKey(event: SlotEvent): string {
|
|
79
|
+
return `calendar:${event.id}@${event.start}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What the Agent is told when a slot starts. */
|
|
83
|
+
export function slotInstruction(event: SlotEvent, agentName: string): string {
|
|
84
|
+
const what = event.description.trim();
|
|
85
|
+
const title = addressedTo(event.title, agentName) || event.title.trim() || "(untitled)";
|
|
86
|
+
return `Your calendar: "${title}" has started.${what ? `\n\n${what}` : "\n\nIt has no description: do your job, as your skills say."}`;
|
|
87
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "A TASK CHANGED" (docs/mcp/MCP_TASKS.md § 5a, S3; step 6). Whatever writes a task's row says
|
|
3
|
+
* so here, once, after the write: the engine when a run starts and ends, the server when a step
|
|
4
|
+
* is saved or a node starts or stops asking. It lives in base because both write rows, in one
|
|
5
|
+
* process; a client following that task (`notifications/tasks`, server/taskListen.ts) is sent the
|
|
6
|
+
* whole task again. Nothing else lives here.
|
|
7
|
+
*/
|
|
8
|
+
const listeners = new Set<(taskId: string) => void>();
|
|
9
|
+
|
|
10
|
+
/** Listen for changed tasks. Returns the way to stop listening. */
|
|
11
|
+
export function onTaskChanged(listener: (taskId: string) => void): () => void {
|
|
12
|
+
listeners.add(listener);
|
|
13
|
+
return () => listeners.delete(listener);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Say a task's row changed. A listener that fails never stops the others. */
|
|
17
|
+
export function taskChanged(taskId: string): void {
|
|
18
|
+
for (const listener of listeners) {
|
|
19
|
+
try {
|
|
20
|
+
listener(taskId);
|
|
21
|
+
} catch {
|
|
22
|
+
/* one entry's failure is its own */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|