@a-t-h-i/bot-lobby 0.6.4 → 0.6.6
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 +150 -12
- package/package.json +12 -3
- package/prompts/backend.md +3 -1
- package/prompts/designer.md +24 -1
- package/prompts/global.md +18 -0
- package/prompts/master.md +122 -20
- package/prompts/quickfix.md +16 -3
- package/prompts/worker.md +7 -2
- package/src/agents/backend.ts +2 -2
- package/src/agents/designer.ts +2 -2
- package/src/ask/dialog.ts +11 -1
- package/src/classifier/triage.ts +24 -7
- package/src/index.ts +4 -1
- package/src/lobby/feed.ts +7 -2
- package/src/lobby/keys.ts +0 -1
- package/src/lobby/quickfix.ts +29 -3
- package/src/lobby/runtime.ts +29 -26
- package/src/lobby/tabs/home.ts +6 -42
- package/src/lobby/tabs/quickfix.ts +1 -1
- package/src/lobby/tabs/tasks.ts +3 -2
- package/src/lobby/view.ts +18 -23
- package/src/master/decisions.ts +10 -2
- package/src/pi/activity.ts +1 -33
- package/src/pi/commands.ts +22 -11
- package/src/pi/events.ts +3 -17
- package/src/pi/plan-checklist.ts +305 -0
- package/src/pi/route.ts +180 -0
- package/src/pi/run-summary.ts +12 -3
- package/src/pi/settings-ui.ts +2 -2
- package/src/pi/start-task.ts +51 -5
- package/src/pi/tools.ts +13 -7
- package/src/pi/ui.ts +18 -284
- package/src/roles/worker.ts +11 -3
- package/src/schemas/configuration.ts +19 -6
- package/src/schemas/task.ts +31 -0
- package/src/workflow/brief.ts +59 -0
- package/src/workflow/track.ts +436 -0
- package/src/workflow/workflow.ts +151 -10
- package/src/pi/expressions.ts +0 -169
- package/src/pi/kaomoji.ts +0 -227
- package/src/pi/mascot-art.ts +0 -359
- package/src/pi/zen-large.ts +0 -699
- package/src/pi/zen-metrics.ts +0 -130
- package/src/pi/zen.ts +0 -659
package/src/workflow/workflow.ts
CHANGED
|
@@ -49,6 +49,7 @@ import { assessReconnaissance, completionBlockers, decideReviewLoop, recordDecis
|
|
|
49
49
|
import { detectSharedFiles, summarizeOutcomes } from "../master/synthesis.ts";
|
|
50
50
|
import { tail, truncate } from "../text.ts";
|
|
51
51
|
import { isAutoMode } from "../state/auto.ts";
|
|
52
|
+
import { briefRejection } from "./brief.ts";
|
|
52
53
|
import { assertNoPendingApprovals, pendingApprovals, requestApproval, resolveApproval } from "./approvals.ts";
|
|
53
54
|
import { pingApproval } from "../pi/notify.ts";
|
|
54
55
|
import { describeRun, runLogEntry } from "../pi/run-summary.ts";
|
|
@@ -59,6 +60,7 @@ import type { EffortRouter } from "../classifier/effort.ts";
|
|
|
59
60
|
import { answerClarify } from "../classifier/triage.ts";
|
|
60
61
|
import { appendMetrics, metricFromRun } from "../state/metrics.ts";
|
|
61
62
|
import { markCommentsAddressed, pendingComments, readPlanComments } from "../state/comments.ts";
|
|
63
|
+
import { fastNext, onFastTrack, parseRoster, qaRequired, qaStillDue, qaTookPart, rosterWords, trackSummary } from "./track.ts";
|
|
62
64
|
|
|
63
65
|
export const ORCHESTRATE_ACTIONS = [
|
|
64
66
|
"clarify",
|
|
@@ -76,6 +78,7 @@ export const ORCHESTRATE_ACTIONS = [
|
|
|
76
78
|
"resume",
|
|
77
79
|
"decide",
|
|
78
80
|
"budget",
|
|
81
|
+
"track",
|
|
79
82
|
"status",
|
|
80
83
|
"cancel",
|
|
81
84
|
] as const;
|
|
@@ -112,6 +115,10 @@ export interface OrchestrateParams {
|
|
|
112
115
|
note?: string;
|
|
113
116
|
reason?: string;
|
|
114
117
|
text?: string;
|
|
118
|
+
/** track: the path the task takes. */
|
|
119
|
+
track?: "fast" | "full";
|
|
120
|
+
/** track: who takes part (designer, backend, qa, researcher). */
|
|
121
|
+
roster?: string[];
|
|
115
122
|
}
|
|
116
123
|
|
|
117
124
|
export interface WorkflowDeps {
|
|
@@ -204,6 +211,7 @@ export function describeTask(task: Task): string {
|
|
|
204
211
|
`${task.id} — state: ${task.state}${task.paused ? " (paused)" : ""}`,
|
|
205
212
|
`Title: ${task.title}`,
|
|
206
213
|
`Request: ${truncate(taskRequest(task), 200)}`,
|
|
214
|
+
task.track ? trackSummary(task.track) : "",
|
|
207
215
|
task.domains.length > 0 ? `Domains: ${task.domains.join(", ")}` : "",
|
|
208
216
|
`Review iterations: ${Object.entries(task.reviewIterations).map(([d, n]) => `${d}=${n}`).join(", ")}`,
|
|
209
217
|
pendingApprovals(task).length > 0
|
|
@@ -512,10 +520,12 @@ function handlePlan(task: Task, params: OrchestrateParams, deps: WorkflowDeps):
|
|
|
512
520
|
requireState(task, ["planning", ...AMEND_PLAN_STATES]);
|
|
513
521
|
const plan = params.plan?.trim();
|
|
514
522
|
if (!plan) throw new Error("plan requires the plan text");
|
|
515
|
-
|
|
523
|
+
// The fast track keeps a short plan: the full plan's required areas are the full workflow's.
|
|
524
|
+
const missing = onFastTrack(task) ? [] : validatePlan(plan);
|
|
516
525
|
if (missing.length > 0) throw new Error(`plan is missing: ${missing.join(", ")}`);
|
|
517
526
|
const amending = AMEND_PLAN_STATES.includes(task.state);
|
|
518
527
|
task.plan = plan;
|
|
528
|
+
if (task.track?.autoPlan) delete task.track.autoPlan;
|
|
519
529
|
writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), plan);
|
|
520
530
|
const addressed = addressComments(task, deps);
|
|
521
531
|
if (amending) {
|
|
@@ -570,7 +580,9 @@ function recordAdvisoryPushbacks(task: Task, entries: Array<{ pushback?: Pushbac
|
|
|
570
580
|
}
|
|
571
581
|
}
|
|
572
582
|
|
|
573
|
-
|
|
583
|
+
const FULL_NEXT = "Next: inspect the diff, then run action=qa once this domain's work is complete.";
|
|
584
|
+
|
|
585
|
+
function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?: Approval, next = FULL_NEXT): string {
|
|
574
586
|
const { result, run, issues } = outcome;
|
|
575
587
|
const objection = result.pushback;
|
|
576
588
|
return [
|
|
@@ -593,7 +605,7 @@ function workerReport(outcome: WorkerOutcome, approvals: Approval[], pushback?:
|
|
|
593
605
|
pushback && objection
|
|
594
606
|
? `Pushback recorded (${pushback.id}): ${truncate(objection.reason, 240)}. Resolve with action=resolve_approval before re-delegating ${result.domain}.`
|
|
595
607
|
: "",
|
|
596
|
-
|
|
608
|
+
next,
|
|
597
609
|
]
|
|
598
610
|
.filter((line) => line.length > 0)
|
|
599
611
|
.join("\n");
|
|
@@ -835,17 +847,46 @@ function absorbWorkerOutcome(task: Task, deps: WorkflowDeps, outcome: WorkerOutc
|
|
|
835
847
|
const pushback = recordPushback(task, outcome);
|
|
836
848
|
task.blockers = [...task.blockers.filter((blocker) => blocker.domain !== domain), ...outcome.result.blockers];
|
|
837
849
|
updateScratchpad(deps, task, outcome);
|
|
838
|
-
const
|
|
839
|
-
|
|
850
|
+
const grown = qaJoinsGrownFastTask(task, outcome);
|
|
851
|
+
const report = workerReport(outcome, approvals, pushback, onFastTrack(task) ? fastNext(task) : FULL_NEXT);
|
|
852
|
+
return [report, grown, timeReport(outcome)].filter(Boolean).join("\n");
|
|
840
853
|
}
|
|
841
854
|
|
|
855
|
+
/**
|
|
856
|
+
* A fast task whose worker asks for a new dependency or an architecture
|
|
857
|
+
* change is not the small change it read as: QA joins, so it is checked
|
|
858
|
+
* before it completes. Returns the line for the oracle, or "".
|
|
859
|
+
*/
|
|
860
|
+
function qaJoinsGrownFastTask(task: Task, outcome: WorkerOutcome): string {
|
|
861
|
+
const track = task.track;
|
|
862
|
+
if (!track || track.path !== "fast" || track.roster.includes("qa")) return "";
|
|
863
|
+
const asks = [...outcome.result.dependencyNeeds, ...outcome.result.architectureChanges];
|
|
864
|
+
if (asks.length === 0) return "";
|
|
865
|
+
track.roster = parseRoster([...track.roster, "qa"]);
|
|
866
|
+
track.reasons = [...track.reasons, `tests: ${AGENT_LABELS[outcome.result.domain]} asked for a dependency or an architecture change`];
|
|
867
|
+
recordDecision(task, `QA joined the fast track: ${AGENT_LABELS[outcome.result.domain]} asked for ${truncate(asks.join("; "), 200)}.`);
|
|
868
|
+
return "QA now takes part: a fast-track change that needs a new dependency or an architecture change is checked before it completes.";
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** States a fast task starts its work from: before anything is planned, and never while the user decides on a proposal. */
|
|
872
|
+
const FAST_START_STATES: readonly TaskState[] = ["created", "clarifying", "scouting", "synthesizing"];
|
|
873
|
+
|
|
842
874
|
async function handleImplement(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
843
|
-
|
|
875
|
+
const starting = onFastTrack(task) && FAST_START_STATES.includes(task.state);
|
|
876
|
+
if (!starting && FAST_START_STATES.includes(task.state)) {
|
|
877
|
+
throw new Error(`action not allowed in state "${task.state}": on the full workflow the user approves a proposal first (a small, clear change can take the fast track: action=track track=fast)`);
|
|
878
|
+
}
|
|
879
|
+
if (!starting) requireState(task, ["planning", "implementing", "reviewing"]);
|
|
844
880
|
const assignments = parseAssignments(params);
|
|
881
|
+
const short = !deps.config.workflow.briefCheck ? [] : assignments.map((entry) => briefRejection(task.id, entry.domain, entry.instruction)).filter(Boolean);
|
|
882
|
+
if (short.length > 0) throw new Error(short.join("\n"));
|
|
845
883
|
for (const { domain } of assignments) assertNoPendingApprovals(task, domain);
|
|
846
884
|
for (const { domain } of assignments) if (!task.domains.includes(domain)) task.domains.push(domain);
|
|
847
885
|
// Under a time budget every step is given its share before any starts; a spent budget starts none.
|
|
848
886
|
const times = workerTimes(task, deps, assignments);
|
|
887
|
+
if (task.track && onFastTrack(task)) task.track.roster = parseRoster([...task.track.roster, ...assignments.map((entry) => entry.domain)]);
|
|
888
|
+
if (starting) startFast(task);
|
|
889
|
+
if (task.track?.autoPlan) addFastSteps(task, deps, assignments);
|
|
849
890
|
if (task.state !== "implementing") transition(task, "implementing");
|
|
850
891
|
await takeBaseline(task, deps);
|
|
851
892
|
let report: string;
|
|
@@ -863,6 +904,61 @@ async function handleImplement(task: Task, params: OrchestrateParams, deps: Work
|
|
|
863
904
|
return note ? `${report}\n\n${note}` : report;
|
|
864
905
|
}
|
|
865
906
|
|
|
907
|
+
/**
|
|
908
|
+
* The fast track's start: the request is small and clear, so it goes from
|
|
909
|
+
* shaping straight to planning without a proposal round (the engine walks the
|
|
910
|
+
* same states, so every transition stays legal), and the engine keeps a short
|
|
911
|
+
* plan whose steps are the delegations themselves.
|
|
912
|
+
*/
|
|
913
|
+
function startFast(task: Task): void {
|
|
914
|
+
const walk: Partial<Record<TaskState, TaskState[]>> = {
|
|
915
|
+
created: ["clarifying", "awaiting_approval", "planning"],
|
|
916
|
+
clarifying: ["awaiting_approval", "planning"],
|
|
917
|
+
scouting: ["synthesizing", "awaiting_approval", "planning"],
|
|
918
|
+
synthesizing: ["awaiting_approval", "planning"],
|
|
919
|
+
};
|
|
920
|
+
for (const state of walk[task.state] ?? []) transition(task, state);
|
|
921
|
+
const track = task.track!;
|
|
922
|
+
if (!task.plan) {
|
|
923
|
+
task.plan = [
|
|
924
|
+
"## Objective",
|
|
925
|
+
oneLine(taskRequest(task), 600),
|
|
926
|
+
"",
|
|
927
|
+
"## Track",
|
|
928
|
+
`Fast track (${track.size}): ${rosterWords(track.roster)}. No scouts, proposal round or plan review; ${qaRequired(task) ? "QA takes part before completion (its tests as the last step, or the QA gate)" : "no QA gate, as nothing here needs tests"}.`,
|
|
929
|
+
"",
|
|
930
|
+
"## Steps",
|
|
931
|
+
].join("\n");
|
|
932
|
+
track.autoPlan = true;
|
|
933
|
+
}
|
|
934
|
+
recordDecision(task, `Fast track: started without a proposal round (${track.size}; ${rosterWords(track.roster)}).`);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/** `Step 3: …`, `Steps 2-4 — …` at the start of a delegation. */
|
|
938
|
+
const STEP_PREFIX = /^\s*(?:\*\*)?steps?\s*#?\s*(\d+)(?:\s*(?:-|\u2013|\u2014|to|and|&)\s*#?\s*(\d+))?(?:\*\*)?\s*[:.)\u2013\u2014-]?\s*/i;
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* The fast track's plan grows with its delegations: a delegation that does
|
|
942
|
+
* not name a step already in the plan adds one, so the lobby's checklist
|
|
943
|
+
* follows the work without a plan document.
|
|
944
|
+
*/
|
|
945
|
+
function addFastSteps(task: Task, deps: WorkflowDeps, assignments: readonly Assignment[]): void {
|
|
946
|
+
const plan = task.plan ?? "";
|
|
947
|
+
const lines = plan.split("\n");
|
|
948
|
+
const heading = lines.findIndex((line) => /^##\s+steps\s*$/i.test(line));
|
|
949
|
+
let count = heading < 0 ? 0 : lines.slice(heading + 1).filter((line) => /^\d+\.\s/.test(line)).length;
|
|
950
|
+
const added: string[] = [];
|
|
951
|
+
for (const { domain, instruction } of assignments) {
|
|
952
|
+
const named = STEP_PREFIX.exec(instruction);
|
|
953
|
+
if (named && Number(named[2] ?? named[1]) <= count) continue;
|
|
954
|
+
count += 1;
|
|
955
|
+
added.push(`${count}. ${AGENT_LABELS[domain]}: ${oneLine(instruction.replace(STEP_PREFIX, ""), 160) || "its part of the request"}`);
|
|
956
|
+
}
|
|
957
|
+
if (added.length === 0) return;
|
|
958
|
+
task.plan = `${plan.trimEnd()}\n${added.join("\n")}`;
|
|
959
|
+
writeFileEnsured(join(taskDirFor(deps.root, deps.configDir, task.id), "plan.md"), task.plan);
|
|
960
|
+
}
|
|
961
|
+
|
|
866
962
|
/**
|
|
867
963
|
* Several domains at once. The workers share one file desk: each claims a file
|
|
868
964
|
* before editing it, queues for a busy one, and hands it over with a note; a
|
|
@@ -1159,9 +1255,10 @@ function handleCompact(task: Task, params: OrchestrateParams, deps: WorkflowDeps
|
|
|
1159
1255
|
|
|
1160
1256
|
/** §63: record history, drop scratchpads, then mark the task completed. */
|
|
1161
1257
|
async function handleComplete(task: Task, params: OrchestrateParams, deps: WorkflowDeps): Promise<string> {
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1258
|
+
// The fast track completes straight from its work; the full workflow from review.
|
|
1259
|
+
requireState(task, onFastTrack(task) ? ["implementing", "reviewing", "blocked"] : ["reviewing", "blocked"]);
|
|
1260
|
+
// Without QA's part (where the track asks for it) only the user can let the task finish: they are asked, never overruled.
|
|
1261
|
+
if (qaRequired(task) && !qaTookPart(task) && !task.qaWaiver) await offerAcceptance(task, deps);
|
|
1165
1262
|
if (task.state === "blocked") throw new Error("cannot complete: the task is blocked, and the user did not accept its work as it is");
|
|
1166
1263
|
const blockers = completionBlockers(task, pendingApprovals(task).length);
|
|
1167
1264
|
if (blockers.length > 0) throw new Error(`cannot complete: ${blockers.join("; ")}`);
|
|
@@ -1170,6 +1267,7 @@ async function handleComplete(task: Task, params: OrchestrateParams, deps: Workf
|
|
|
1170
1267
|
recordCompletion(deps, task, summary);
|
|
1171
1268
|
removeTaskScratchpads(deps.root, deps.configDir, task.id);
|
|
1172
1269
|
task.blockers = [];
|
|
1270
|
+
if (task.state === "implementing") transition(task, "reviewing");
|
|
1173
1271
|
transition(task, "completed");
|
|
1174
1272
|
const oversized = overThreshold(readDataRoots(deps.root, deps.configDir), deps.config.knowledge.compactionThreshold);
|
|
1175
1273
|
const advice =
|
|
@@ -1227,6 +1325,48 @@ function handleResume(task: Task, params: OrchestrateParams): string {
|
|
|
1227
1325
|
return "Task resumed. Continue with action=implement.";
|
|
1228
1326
|
}
|
|
1229
1327
|
|
|
1328
|
+
/**
|
|
1329
|
+
* `action=track`: with neither `track` nor `roster`, the task's track; with
|
|
1330
|
+
* them, the oracle corrects the engine's read of the request. The fast track
|
|
1331
|
+
* is taken only before the work is planned, never against the user's --full
|
|
1332
|
+
* or the settings; once work is under way a track only gets stricter (the
|
|
1333
|
+
* full workflow, more members, QA never dropped).
|
|
1334
|
+
*/
|
|
1335
|
+
function handleTrack(task: Task, params: OrchestrateParams, deps: WorkflowDeps): string {
|
|
1336
|
+
const current = task.track;
|
|
1337
|
+
if (!params.track && !params.roster) return current ? trackSummary(current) : "This task began before tracks: it takes the full workflow.";
|
|
1338
|
+
const reason = oneLine(params.reason?.trim() || params.text?.trim() || "", 240);
|
|
1339
|
+
if (!reason) throw new Error("track requires reason: why the task takes that path, or needs those members");
|
|
1340
|
+
const path = params.track ?? current?.path ?? "full";
|
|
1341
|
+
const shaping = FAST_START_STATES.includes(task.state);
|
|
1342
|
+
if (path === "fast" && current?.path !== "fast") {
|
|
1343
|
+
if (!deps.config.workflow.fastTrack) throw new Error("the fast track is off in settings (workflow.fastTrack): this task takes the full workflow");
|
|
1344
|
+
if (current?.userChoice === "full") throw new Error("the user asked for the full workflow on this task (--full)");
|
|
1345
|
+
if (!shaping) throw new Error(`a task takes the fast track before its work is planned; this one is ${task.state}`);
|
|
1346
|
+
}
|
|
1347
|
+
const asked = params.roster ? parseRoster(params.roster) : [...(current?.roster ?? [])];
|
|
1348
|
+
// The full workflow always ends with the QA gate.
|
|
1349
|
+
const roster = path === "full" ? parseRoster([...asked, "qa"]) : asked;
|
|
1350
|
+
if (!shaping && current?.roster.includes("qa") && !roster.includes("qa")) throw new Error("QA stays on the roster once the work is under way");
|
|
1351
|
+
const grew = current?.path === "fast" && path === "full";
|
|
1352
|
+
task.track = {
|
|
1353
|
+
path,
|
|
1354
|
+
size: current?.size ?? "small",
|
|
1355
|
+
roster,
|
|
1356
|
+
reasons: [`oracle: ${reason}`, ...(current?.reasons ?? [])].slice(0, 8),
|
|
1357
|
+
source: "oracle",
|
|
1358
|
+
...(current?.userChoice ? { userChoice: current.userChoice } : {}),
|
|
1359
|
+
...(current?.autoPlan ? { autoPlan: true } : {}),
|
|
1360
|
+
at: new Date().toISOString(),
|
|
1361
|
+
};
|
|
1362
|
+
recordDecision(task, `Track: ${path === "fast" ? "fast track" : "full workflow"}; ${rosterWords(roster)} — ${reason}`);
|
|
1363
|
+
if (path === "fast") {
|
|
1364
|
+
return `Fast track: ${rosterWords(roster)}. Delegate straight away with action=implement (open each task with "Step N:"); no scouts, proposal or plan. ${qaRequired(task) ? "QA takes part before completion: its tests as the last step, or action=qa." : "No QA gate: nothing here needs tests."} Then action=complete.`;
|
|
1365
|
+
}
|
|
1366
|
+
if (!shaping) return `Full workflow from here: ${rosterWords(roster)}. The QA gate runs before the task completes${grew ? "; tell the user in one line why the task grew" : ""}.`;
|
|
1367
|
+
return `Full workflow: ${rosterWords(roster)}. Scout what the request touches, then propose a short bullet list for approval.`;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1230
1370
|
function handleDecide(task: Task, params: OrchestrateParams): string {
|
|
1231
1371
|
const text = params.text?.trim();
|
|
1232
1372
|
if (!text) throw new Error("decide requires text");
|
|
@@ -1254,7 +1394,7 @@ type WorkerTime = AgentTime & { id: string };
|
|
|
1254
1394
|
/** The task's budget and where it stands, when it has one. */
|
|
1255
1395
|
function budgetFor(task: Task, deps: WorkflowDeps): { budget: TaskBudget; state: BudgetState } | undefined {
|
|
1256
1396
|
const budget = readBudget(deps.root, deps.configDir, task.id);
|
|
1257
|
-
return budget ? { budget, state: budgetState(task.id, budget, task
|
|
1397
|
+
return budget ? { budget, state: budgetState(task.id, budget, !qaStillDue(task)) } : undefined;
|
|
1258
1398
|
}
|
|
1259
1399
|
|
|
1260
1400
|
/** No new work starts once the budget is spent: the oracle asks the user for more, or wraps up. */
|
|
@@ -1501,6 +1641,7 @@ const HANDLERS: Record<OrchestrateAction, (task: Task, params: OrchestrateParams
|
|
|
1501
1641
|
resume: handleResume,
|
|
1502
1642
|
decide: handleDecide,
|
|
1503
1643
|
budget: handleBudget,
|
|
1644
|
+
track: handleTrack,
|
|
1504
1645
|
status: (task) => describeTask(task),
|
|
1505
1646
|
cancel: handleCancel,
|
|
1506
1647
|
};
|
package/src/pi/expressions.ts
DELETED
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Caller-driven expression schedule for the zen sprites.
|
|
3
|
-
*
|
|
4
|
-
* Pure and deterministic: `now` and the random source are always injected, so
|
|
5
|
-
* the art, layout and metrics stay free of `Date.now`/`Math.random`. Each slot
|
|
6
|
-
* and the oracle keeps its own state, holds its rest frame between events and,
|
|
7
|
-
* after a random 20-30 s gap, plays a short blink or a longer emote.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Resting gap before the next expression, milliseconds. */
|
|
11
|
-
export const BLINK_MIN_MS = 20_000;
|
|
12
|
-
export const BLINK_MAX_MS = 30_000;
|
|
13
|
-
/** How long one blink and one emote are held. */
|
|
14
|
-
export const BLINK_MS = 500;
|
|
15
|
-
export const EMOTE_MS = 2000;
|
|
16
|
-
/** Fast clock while an expression plays, short enough that a blink is never skipped. */
|
|
17
|
-
export const FAST_TICK_MS = 120;
|
|
18
|
-
|
|
19
|
-
/** Art frame indexes: 0 rests, 1 blinks, `2 ..` are emotes. */
|
|
20
|
-
export const REST_FRAME = 0;
|
|
21
|
-
export const BLINK_FRAME = 1;
|
|
22
|
-
export const EMOTE_FRAME = 2;
|
|
23
|
-
/**
|
|
24
|
-
* Emote frame range: `EMOTE_FRAME .. EMOTE_FRAME + EMOTE_FRAMES - 1`; an emote
|
|
25
|
-
* steps through them: the open face, a blink, then its action (see kaomoji.ts).
|
|
26
|
-
*/
|
|
27
|
-
export const EMOTE_FRAMES = 4;
|
|
28
|
-
/** Each emote frame is held this long, so an emote steps through its frames. */
|
|
29
|
-
export const EMOTE_STEP_MS = Math.floor(EMOTE_MS / EMOTE_FRAMES);
|
|
30
|
-
|
|
31
|
-
const BLINK_CHANCE = 0.65;
|
|
32
|
-
/** Working agents emote as often as they blink. */
|
|
33
|
-
export const WORKING_BLINK_CHANCE = 0.5;
|
|
34
|
-
/** Upper bound (exclusive) of an expression's variant. */
|
|
35
|
-
const VARIANT_SPAN = 2 ** 30;
|
|
36
|
-
|
|
37
|
-
export interface ExpressionState {
|
|
38
|
-
/** Earliest `now` the next expression may start. */
|
|
39
|
-
nextAt: number;
|
|
40
|
-
/** When the playing expression ends; not after `now` while resting. */
|
|
41
|
-
until: number;
|
|
42
|
-
/** When the current expression started; drives emote frame stepping. */
|
|
43
|
-
startedAt: number;
|
|
44
|
-
/** Frame index for the art: 0 rest, 1 blink, 2+ emote. */
|
|
45
|
-
frame: number;
|
|
46
|
-
/** Drawn once per expression; picks which face an emote shows (see kaomoji.ts). */
|
|
47
|
-
variant: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A random draw clamped to `[0, 1]`, so a non-finite source value can never leak. */
|
|
51
|
-
function unit(rng: () => number): number {
|
|
52
|
-
const value = rng();
|
|
53
|
-
return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Resting window between expressions, milliseconds. */
|
|
57
|
-
export interface ExpressionGap {
|
|
58
|
-
min: number;
|
|
59
|
-
max: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Idle agent slots rest 20-30 s between expressions. */
|
|
63
|
-
export const SLOT_GAP: ExpressionGap = { min: BLINK_MIN_MS, max: BLINK_MAX_MS };
|
|
64
|
-
/** Working agents are livelier: 8-15 s between expressions. */
|
|
65
|
-
export const WORKING_GAP: ExpressionGap = { min: 8_000, max: 15_000 };
|
|
66
|
-
/** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
|
|
67
|
-
export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
|
|
68
|
-
|
|
69
|
-
/** Random resting gap before the next expression, inside `gap` (the slot window by default). */
|
|
70
|
-
export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): number {
|
|
71
|
-
return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Whether the next expression blinks (~65% by default) or emotes. */
|
|
75
|
-
export function pickEvent(rng: () => number, blinkChance = BLINK_CHANCE): "blink" | "emote" {
|
|
76
|
-
return unit(rng) < blinkChance ? "blink" : "emote";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function drawVariant(rng: () => number): number {
|
|
80
|
-
return Math.floor(unit(rng) * (VARIANT_SPAN - 1));
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** A freshly rested expression that fires for the first time after one random gap. */
|
|
84
|
-
export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
|
|
85
|
-
const start = Number.isFinite(now) ? now : 0;
|
|
86
|
-
return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME, variant: 0 };
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** True while `now` is inside a playing blink or emote. */
|
|
90
|
-
export function isPlaying(state: ExpressionState, now: number): boolean {
|
|
91
|
-
return Number.isFinite(now) && now < state.until;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** True when any state is mid-expression; the caller uses it to retime its clock. */
|
|
95
|
-
export function anyPlaying(states: readonly ExpressionState[], now: number): boolean {
|
|
96
|
-
return states.some((state) => isPlaying(state, now));
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
|
|
101
|
-
* drop back to the rest frame when it ends, and change nothing in between.
|
|
102
|
-
*/
|
|
103
|
-
export function advanceExpression(
|
|
104
|
-
state: ExpressionState,
|
|
105
|
-
now: number,
|
|
106
|
-
rng: () => number,
|
|
107
|
-
gap: ExpressionGap = SLOT_GAP,
|
|
108
|
-
blinkChance = BLINK_CHANCE,
|
|
109
|
-
): ExpressionState {
|
|
110
|
-
if (!Number.isFinite(now)) return state;
|
|
111
|
-
if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
|
|
112
|
-
if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
|
|
113
|
-
return play(now, rng, gap, blinkChance);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function play(now: number, rng: () => number, gap: ExpressionGap, blinkChance: number): ExpressionState {
|
|
117
|
-
const blink = pickEvent(rng, blinkChance) === "blink";
|
|
118
|
-
return blink ? startBlink(now, rng, gap) : triggerEmote(now, rng, gap);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function startBlink(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
|
|
122
|
-
const until = now + BLINK_MS;
|
|
123
|
-
return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: BLINK_FRAME, variant: drawVariant(rng) };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Start an emote right now, whatever the sprite was doing: the reaction when a
|
|
128
|
-
* slot's situation changes (it starts, finishes, fails, stalls, waits or
|
|
129
|
-
* receives a file). The next scheduled expression follows one gap later.
|
|
130
|
-
*/
|
|
131
|
-
export function triggerEmote(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
|
|
132
|
-
const start = Number.isFinite(now) ? now : 0;
|
|
133
|
-
const until = start + EMOTE_MS;
|
|
134
|
-
return { nextAt: until + nextGap(rng, gap), until, startedAt: start, frame: EMOTE_FRAME, variant: drawVariant(rng) };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** One sub-step of a playing expression; the oracle's blink and glance step through these. */
|
|
138
|
-
export const PHASE_MS = FAST_TICK_MS;
|
|
139
|
-
|
|
140
|
-
/** Sub-steps elapsed in the playing expression, 0 while resting or on a bad clock. */
|
|
141
|
-
export function expressionPhase(state: ExpressionState, now: number): number {
|
|
142
|
-
if (!isPlaying(state, now)) return 0;
|
|
143
|
-
const elapsed = now - state.startedAt;
|
|
144
|
-
return Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / PHASE_MS) : 0;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** How long the oracle lip-syncs after it says something new, and one mouth shape's hold. */
|
|
148
|
-
export const TALK_MS = 1800;
|
|
149
|
-
export const TALK_STEP_MS = FAST_TICK_MS;
|
|
150
|
-
|
|
151
|
-
/** Mouth-shape index while the oracle is still talking about what it said at `since`, else undefined. */
|
|
152
|
-
export function talkFrame(since: number | undefined, now: number): number | undefined {
|
|
153
|
-
if (since === undefined || !Number.isFinite(since) || !Number.isFinite(now)) return undefined;
|
|
154
|
-
const elapsed = now - since;
|
|
155
|
-
return elapsed >= 0 && elapsed < TALK_MS ? Math.floor(elapsed / TALK_STEP_MS) : undefined;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
|
|
159
|
-
function emoteFrameAt(startedAt: number, now: number): number {
|
|
160
|
-
const elapsed = now - startedAt;
|
|
161
|
-
const step = Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / EMOTE_STEP_MS) : 0;
|
|
162
|
-
return EMOTE_FRAME + Math.min(EMOTE_FRAMES - 1, Math.max(0, step));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/** Advance an in-flight emote's frame, keeping the same object when it has not changed. */
|
|
166
|
-
function steppedEmote(state: ExpressionState, now: number): ExpressionState {
|
|
167
|
-
const frame = emoteFrameAt(state.startedAt, now);
|
|
168
|
-
return frame === state.frame ? state : { ...state, frame };
|
|
169
|
-
}
|