@eventmodelers/cli 1.0.58 → 1.0.59
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 +43 -14
- package/cli.js +197 -39
- package/package.json +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +110 -46
package/README.md
CHANGED
|
@@ -220,27 +220,56 @@ reading its own `.eventmodelers/config.json`.
|
|
|
220
220
|
|
|
221
221
|
Without `--standalone` the agent only ever answers direct messages. With it, the loop also
|
|
222
222
|
subscribes to the board's own change channel — the same one the canvas and the build agents
|
|
223
|
-
use — and
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
223
|
+
use — and the agent becomes a background collaborator on the board: when it falls quiet after
|
|
224
|
+
someone edits it, and again whenever the board has simply been sitting still for a while
|
|
225
|
+
(`EVENTMODELERS_STANDALONE_IDLE_MS`), the agent gets a turn nobody asked for.
|
|
226
|
+
|
|
227
|
+
What it does with that turn is *not* "react to the last event". The changed nodes are a
|
|
228
|
+
notification telling it where to look. The agent itself analyses all of them against the model
|
|
229
|
+
as a whole — each changed area in its slice and chain, plus whatever else is still obviously
|
|
230
|
+
unfinished — and decides what needs doing. Then it fans the work out: **one subagent per piece
|
|
231
|
+
of work that needs doing, all dispatched in parallel** (pieces sharing a slice or chain are
|
|
232
|
+
merged into one agent, so no two agents write to the same area). The decision stays with the
|
|
233
|
+
main agent; each subagent is an executor that carries out the one piece it was given, invoking
|
|
234
|
+
the matching skill for its own target — example data on a freshly placed element, a missing
|
|
235
|
+
attribute on the rest of the chain, a screen for an empty SCREEN node, a question comment on a
|
|
236
|
+
gap. Nothing needing doing means no agents are spawned at all: the turn adds nothing and
|
|
237
|
+
answers `NOOP` (see the "Standalone board-change turns" section in
|
|
229
238
|
`.agent-modeling-kit/CLAUDE.md`).
|
|
230
239
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
240
|
+
`--max-agents <n>` caps that fan-out, so an unattended turn's cost stays bounded — default 5:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npx @eventmodelers/cli run --standalone --board-id <uuid> --max-agents 3
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Work sharing a slice or chain is merged into one agent first (that part is about not clobbering
|
|
247
|
+
the board, not about the cap); if more pieces are still left than the cap allows, the agent
|
|
248
|
+
dispatches the most valuable ones and leaves the rest for a later turn. `--max-agents 1` means
|
|
249
|
+
no subagents at all: the turn does the single most valuable piece itself. The cap rides along in
|
|
250
|
+
the turn's own instructions rather than being enforced from outside — the `claude` process is
|
|
251
|
+
what spawns the agents — so it's a budget the agent is told to keep, not a hard ceiling.
|
|
252
|
+
|
|
253
|
+
Every event that arrives is remembered until a turn carries it — including events that land
|
|
254
|
+
while a turn is running. Its own writes come back on that same channel and the platform can't
|
|
255
|
+
tell them apart from a human's, so those are *labelled* for the agent rather than discarded,
|
|
256
|
+
and the lane is damped on timing instead: it waits for a quiet period (but not forever), waits
|
|
257
|
+
out the echo window of its last turn, never fires twice in quick succession, and widens that
|
|
258
|
+
floor each time it answers `NOOP`, so a finished board goes quiet by itself. Override the
|
|
259
|
+
windows if the defaults don't suit your board:
|
|
235
260
|
|
|
236
261
|
| Env var | Default | What it controls |
|
|
237
262
|
|---|---|---|
|
|
238
|
-
| `EVENTMODELERS_STANDALONE_DEBOUNCE_MS` | `8000` | quiet period before
|
|
239
|
-
| `
|
|
263
|
+
| `EVENTMODELERS_STANDALONE_DEBOUNCE_MS` | `8000` | quiet period before buffered board changes turn into a turn |
|
|
264
|
+
| `EVENTMODELERS_STANDALONE_MAX_WAIT_MS` | `90000` | cap on that quiet period, so a board being edited continuously still gets a turn |
|
|
265
|
+
| `EVENTMODELERS_STANDALONE_ECHO_WINDOW_MS` | `20000` | after a turn, how long incoming changes are labelled as probably the agent's own echo |
|
|
240
266
|
| `EVENTMODELERS_STANDALONE_MIN_INTERVAL_MS` | `60000` | floor between two self-directed turns |
|
|
267
|
+
| `EVENTMODELERS_STANDALONE_BACKOFF_CAP_MS` | `900000` | ceiling that floor doubles up to while turns keep answering `NOOP` |
|
|
268
|
+
| `EVENTMODELERS_STANDALONE_IDLE_MS` | `900000` | with nothing happening at all, how long before the agent reviews the model anyway (`0` disables it) |
|
|
241
269
|
|
|
242
|
-
Direct prompts always outrank the agent's own initiative — a
|
|
243
|
-
anything from the prompt queue is running
|
|
270
|
+
Direct prompts always outrank the agent's own initiative — a self-directed turn waits while
|
|
271
|
+
anything from the prompt queue is running, and the changes it was about keep accumulating
|
|
272
|
+
meanwhile.
|
|
244
273
|
|
|
245
274
|
### Installing skills globally
|
|
246
275
|
|
package/cli.js
CHANGED
|
@@ -393,6 +393,26 @@ async function promptPasteBlock() {
|
|
|
393
393
|
// rather than silently disabling platform sync.
|
|
394
394
|
const DEFAULT_BASE_URL = 'https://api.eventmodelers.ai';
|
|
395
395
|
|
|
396
|
+
// How many subagents a self-directed --standalone turn may dispatch at once. It's a
|
|
397
|
+
// budget, not a mechanism: the turn runs inside one `claude` process, which is what
|
|
398
|
+
// actually spawns the agents, so the cap reaches it as part of the turn's instructions
|
|
399
|
+
// (buildStandaloneTurn) rather than as something the CLI can enforce from outside. Five
|
|
400
|
+
// keeps an unattended turn's cost in the same ballpark as a single prompt turn while
|
|
401
|
+
// still covering the usual burst — a handful of nodes across a couple of slices.
|
|
402
|
+
const DEFAULT_MAX_AGENTS = 5;
|
|
403
|
+
|
|
404
|
+
// `--max-agents` is a cost guard, so a typo must not silently turn into "no limit" or
|
|
405
|
+
// into the default: anything that isn't a positive integer is rejected outright.
|
|
406
|
+
function parseMaxAgents(raw) {
|
|
407
|
+
if (raw === undefined || raw === null || raw === '') return DEFAULT_MAX_AGENTS;
|
|
408
|
+
const n = Number(raw);
|
|
409
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
410
|
+
console.error(`❌ --max-agents must be a positive integer (got "${raw}").`);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
return n;
|
|
414
|
+
}
|
|
415
|
+
|
|
396
416
|
// This CLI's own version, stamped into every install manifest so the global modeling
|
|
397
417
|
// install can tell whether it was written by the version now running (see ensureGlobalKit).
|
|
398
418
|
const CLI_VERSION = readJsonSafe(join(__dirname, 'package.json')).version || '0.0.0';
|
|
@@ -1595,7 +1615,7 @@ async function ensureGlobalKit(baseUrl) {
|
|
|
1595
1615
|
// question, sketch a screen. Without the flag that channel is still subscribed on
|
|
1596
1616
|
// the same connection and every event on it is dropped, so the two modes differ by
|
|
1597
1617
|
// one filter rather than by a whole second realtime stack.
|
|
1598
|
-
async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null) {
|
|
1618
|
+
async function runModeling(kitDir, projectDir, verbose = false, standalone = false, overrides = null, maxAgents = DEFAULT_MAX_AGENTS) {
|
|
1599
1619
|
const configLibPath = join(kitDir, 'lib', 'config.js');
|
|
1600
1620
|
if (!existsSync(configLibPath)) {
|
|
1601
1621
|
console.error(`❌ ${relative(process.cwd(), configLibPath)} not found — --modeling needs a kit installed via \`init --modeling\`.`);
|
|
@@ -1648,7 +1668,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
|
|
|
1648
1668
|
function withSessionHeader(body) {
|
|
1649
1669
|
if (!firstTurn) return body;
|
|
1650
1670
|
firstTurn = false;
|
|
1651
|
-
return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
|
|
1671
|
+
return `MODE=modeling token=${cfg.token} org=${cfg.organizationId} baseUrl=${cfg.baseUrl} standalone=${standalone ? 'on' : 'off'}${standalone ? ` max_agents=${maxAgents}` : ''}\n\n${QUESTIONING_RULE}Read .agent-modeling-kit/CLAUDE.md now and follow it for every prompt in this session — it's a one-time read; don't re-read it on later turns.\n\n${body}`;
|
|
1652
1672
|
}
|
|
1653
1673
|
|
|
1654
1674
|
function buildTurn(p) {
|
|
@@ -1731,7 +1751,9 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
|
|
|
1731
1751
|
lastTurnEndedAt = Date.now();
|
|
1732
1752
|
const turn = pending;
|
|
1733
1753
|
pending = null;
|
|
1734
|
-
|
|
1754
|
+
// The result text is what a standalone turn's NOOP/DONE answer rides in — the
|
|
1755
|
+
// self-directed lane reads it to decide whether to back off (dispatchStandaloneTurn).
|
|
1756
|
+
if (turn) (msg.is_error ? turn.reject(new Error(msg.result || 'Claude turn errored')) : turn.resolve(msg.result ?? ''));
|
|
1735
1757
|
}
|
|
1736
1758
|
}
|
|
1737
1759
|
|
|
@@ -1769,7 +1791,7 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
|
|
|
1769
1791
|
spawnProcess();
|
|
1770
1792
|
log(
|
|
1771
1793
|
standalone
|
|
1772
|
-
?
|
|
1794
|
+
? `standalone: ON — reacting to direct prompts AND to board changes on its own initiative (max ${maxAgents} subagent(s) per self-directed turn)`
|
|
1773
1795
|
: 'standalone: off — reacting to direct prompts only (board changes are dropped)',
|
|
1774
1796
|
);
|
|
1775
1797
|
|
|
@@ -1808,6 +1830,9 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
|
|
|
1808
1830
|
}
|
|
1809
1831
|
} finally {
|
|
1810
1832
|
draining = false;
|
|
1833
|
+
// A prompt turn counts as activity: the board isn't idle just because nobody edited
|
|
1834
|
+
// it while the agent was busy answering someone.
|
|
1835
|
+
armIdleReview();
|
|
1811
1836
|
}
|
|
1812
1837
|
}
|
|
1813
1838
|
|
|
@@ -1823,55 +1848,91 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
|
|
|
1823
1848
|
// later adds to these payloads lands here without a client change.
|
|
1824
1849
|
const BOARD_CHANGE_EVENTS = ['node:created', 'node:changed', 'node:deleted', 'edge:added', 'edge:removed', 'board:cleared'];
|
|
1825
1850
|
|
|
1826
|
-
//
|
|
1851
|
+
// Four knobs, because a board event can't tell you who caused it: the platform
|
|
1827
1852
|
// attributes an API token's writes to the org owner's user_id, so on this channel the
|
|
1828
|
-
// agent's own edits are indistinguishable from the human's.
|
|
1853
|
+
// agent's own edits are indistinguishable from the human's. They all govern *when* a
|
|
1854
|
+
// self-directed turn fires — never whether an event is remembered. Everything that
|
|
1855
|
+
// arrives is buffered (see onBoardEvent): an event seen while a turn runs, or inside the
|
|
1856
|
+
// echo window, is only *marked* as possibly the agent's own write, so a burst that
|
|
1857
|
+
// straddles a turn boundary still reaches the next turn instead of being thrown away and
|
|
1858
|
+
// leaving the agent looking at whichever single event happened to land last.
|
|
1829
1859
|
// DEBOUNCE — one gesture (place a node, drag a column) fans out into several
|
|
1830
1860
|
// events; wait for the board to fall quiet, then send a single turn.
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1861
|
+
// MAX_WAIT — cap on that quiet period: a board someone keeps editing never falls
|
|
1862
|
+
// quiet, and the debounce alone would slide forever.
|
|
1863
|
+
// ECHO_WINDOW — how long after a turn its own writes are expected back; changes in
|
|
1864
|
+
// that window are labelled, and the next turn waits it out.
|
|
1833
1865
|
// MIN_INTERVAL — a floor between self-directed turns, so a mistake upstream can't
|
|
1834
|
-
// become a self-feeding loop burning tokens unattended.
|
|
1866
|
+
// become a self-feeding loop burning tokens unattended. Doubles per
|
|
1867
|
+
// consecutive NOOP up to BACKOFF_CAP, and resets as soon as a turn
|
|
1868
|
+
// actually does something.
|
|
1869
|
+
// IDLE — with nothing at all happening on the board, how long before the agent
|
|
1870
|
+
// looks the model over anyway (a BOARD_REVIEW turn). 0 disables it, and
|
|
1871
|
+
// it answers to the same MIN_INTERVAL backoff, so a board with nothing
|
|
1872
|
+
// left to do goes quiet by itself rather than being swept every IDLE ms.
|
|
1835
1873
|
const envMs = (name, fallback) => {
|
|
1836
1874
|
const raw = Number(process.env[name]);
|
|
1837
1875
|
return Number.isFinite(raw) && raw >= 0 ? raw : fallback;
|
|
1838
1876
|
};
|
|
1839
1877
|
const STANDALONE_DEBOUNCE_MS = envMs('EVENTMODELERS_STANDALONE_DEBOUNCE_MS', 8_000);
|
|
1878
|
+
const STANDALONE_MAX_WAIT_MS = envMs('EVENTMODELERS_STANDALONE_MAX_WAIT_MS', 90_000);
|
|
1840
1879
|
const STANDALONE_ECHO_WINDOW_MS = envMs('EVENTMODELERS_STANDALONE_ECHO_WINDOW_MS', 20_000);
|
|
1841
1880
|
const STANDALONE_MIN_INTERVAL_MS = envMs('EVENTMODELERS_STANDALONE_MIN_INTERVAL_MS', 60_000);
|
|
1842
|
-
|
|
1843
|
-
const
|
|
1881
|
+
const STANDALONE_BACKOFF_CAP_MS = envMs('EVENTMODELERS_STANDALONE_BACKOFF_CAP_MS', 15 * 60_000);
|
|
1882
|
+
const STANDALONE_IDLE_MS = envMs('EVENTMODELERS_STANDALONE_IDLE_MS', 15 * 60_000);
|
|
1883
|
+
|
|
1884
|
+
// node_id (or '(board)') -> { types: Set<string>, count: number, maybeOwn: boolean }
|
|
1885
|
+
// for everything seen since the last self-directed turn. `maybeOwn` stays true only
|
|
1886
|
+
// while every event for that node arrived while a turn was running or inside the echo
|
|
1887
|
+
// window — one event from outside that window and the node is a real change again.
|
|
1888
|
+
const observed = new Map();
|
|
1844
1889
|
let observedCount = 0;
|
|
1845
1890
|
let seqLo = null;
|
|
1846
1891
|
let seqHi = null;
|
|
1847
1892
|
let standaloneTimer = null;
|
|
1893
|
+
let firstObservedAt = 0; // start of the current burst — MAX_WAIT is measured from here
|
|
1848
1894
|
let lastStandaloneAt = 0;
|
|
1895
|
+
let noopStreak = 0;
|
|
1896
|
+
|
|
1897
|
+
// The floor between self-directed turns, widened while the agent keeps finding nothing
|
|
1898
|
+
// to do. A quiet board therefore costs a turn every MIN_INTERVAL, then 2×, 4×, … up to
|
|
1899
|
+
// BACKOFF_CAP, instead of one per interval forever.
|
|
1900
|
+
function minIntervalMs() {
|
|
1901
|
+
return Math.min(STANDALONE_MIN_INTERVAL_MS * 2 ** noopStreak, STANDALONE_BACKOFF_CAP_MS);
|
|
1902
|
+
}
|
|
1849
1903
|
|
|
1850
1904
|
function onBoardEvent(type, payload) {
|
|
1851
1905
|
if (!standalone) {
|
|
1852
1906
|
if (verbose) log(`board event ${type} dropped — not running with --standalone`);
|
|
1853
1907
|
return;
|
|
1854
1908
|
}
|
|
1855
|
-
if (pending || draining) {
|
|
1856
|
-
if (verbose) log(`board event ${type} dropped — a turn is in flight (assumed own write)`);
|
|
1857
|
-
return;
|
|
1858
|
-
}
|
|
1859
1909
|
const sinceTurn = Date.now() - lastTurnEndedAt;
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
return;
|
|
1863
|
-
}
|
|
1910
|
+
const inEchoWindow = !!lastTurnEndedAt && sinceTurn < STANDALONE_ECHO_WINDOW_MS;
|
|
1911
|
+
const maybeOwn = !!pending || draining || inEchoWindow;
|
|
1864
1912
|
const nodeId = payload?.node_id ?? '(board)';
|
|
1865
|
-
|
|
1866
|
-
|
|
1913
|
+
const entry = observed.get(nodeId) ?? { types: new Set(), count: 0, maybeOwn };
|
|
1914
|
+
entry.types.add(type);
|
|
1915
|
+
entry.count += 1;
|
|
1916
|
+
if (!maybeOwn) entry.maybeOwn = false;
|
|
1917
|
+
observed.set(nodeId, entry);
|
|
1867
1918
|
observedCount += 1;
|
|
1919
|
+
if (!firstObservedAt) firstObservedAt = Date.now();
|
|
1868
1920
|
const seq = Number(payload?.seq);
|
|
1869
1921
|
if (Number.isFinite(seq)) {
|
|
1870
1922
|
if (seqLo === null || seq < seqLo) seqLo = seq;
|
|
1871
1923
|
if (seqHi === null || seq > seqHi) seqHi = seq;
|
|
1872
1924
|
}
|
|
1873
|
-
log(`board change: ${type} node=${nodeId}${Number.isFinite(seq) ? ` seq=${seq}` : ''}`);
|
|
1874
|
-
armStandaloneTurn(
|
|
1925
|
+
log(`board change: ${type} node=${nodeId}${Number.isFinite(seq) ? ` seq=${seq}` : ''}${maybeOwn ? ' (maybe own write)' : ''}`);
|
|
1926
|
+
armStandaloneTurn(nextDelayMs());
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// Debounce, but never past MAX_WAIT from the first event of the burst, and never before
|
|
1930
|
+
// the echo window of the last turn has run out.
|
|
1931
|
+
function nextDelayMs() {
|
|
1932
|
+
const waitedSoFar = firstObservedAt ? Date.now() - firstObservedAt : 0;
|
|
1933
|
+
const debounce = Math.max(0, Math.min(STANDALONE_DEBOUNCE_MS, STANDALONE_MAX_WAIT_MS - waitedSoFar));
|
|
1934
|
+
const echoLeft = lastTurnEndedAt ? STANDALONE_ECHO_WINDOW_MS - (Date.now() - lastTurnEndedAt) : 0;
|
|
1935
|
+
return Math.max(debounce, echoLeft, 0);
|
|
1875
1936
|
}
|
|
1876
1937
|
|
|
1877
1938
|
function armStandaloneTurn(delayMs) {
|
|
@@ -1882,51 +1943,140 @@ async function runModeling(kitDir, projectDir, verbose = false, standalone = fal
|
|
|
1882
1943
|
}, delayMs);
|
|
1883
1944
|
}
|
|
1884
1945
|
|
|
1946
|
+
// The changed-node list is a *pointer*, not the job: it says which corners of the board
|
|
1947
|
+
// someone just touched. The turn's actual task is for the agent to analyse all of them
|
|
1948
|
+
// against the model as a whole and then fan out — a subagent per piece of work that really
|
|
1949
|
+
// needs doing, running in parallel. Without that framing the agent treats the last event as
|
|
1950
|
+
// its work item and does one narrow thing (or nothing) even when the model needs something
|
|
1951
|
+
// else entirely, which is exactly what a burst of events on several nodes used to degrade
|
|
1952
|
+
// into.
|
|
1953
|
+
// The fan-out budget (`--max-agents`). A turn nobody asked for still costs money, so the
|
|
1954
|
+
// cap is stated in the turn itself — the `claude` process is what spawns the agents, and
|
|
1955
|
+
// the CLI has no way to count them from out here.
|
|
1956
|
+
const AGENT_BUDGET =
|
|
1957
|
+
maxAgents > 1
|
|
1958
|
+
? `Dispatch at most ${maxAgents} Agents in this turn (--max-agents=${maxAgents}). Merge pieces that share a slice or ` +
|
|
1959
|
+
'chain first — that is a correctness rule, not a way to fit the cap — and if more than that is still left, ' +
|
|
1960
|
+
'take the most valuable pieces up to the cap and leave the rest; a later turn will see them again.'
|
|
1961
|
+
: 'Do not dispatch any Agents in this turn (--max-agents=1) — that budget overrides the fan-out above: do ' +
|
|
1962
|
+
'the single most valuable piece of work yourself, inline, and leave the rest for a later turn.';
|
|
1963
|
+
|
|
1964
|
+
const STANDALONE_TASK =
|
|
1965
|
+
'Nobody asked you for this — you are working on this board in the background, on your own initiative. ' +
|
|
1966
|
+
'The change list above is a notification, not the task: it tells you where something just happened and ' +
|
|
1967
|
+
'which parts of the model to look at first. The task is to judge the model as a whole — each changed area ' +
|
|
1968
|
+
'in its context (its slice, its chain, the timeline around it), plus anything still obviously unfinished ' +
|
|
1969
|
+
'elsewhere — and then get the useful work done. Do not stop at the last event, and do not treat the ' +
|
|
1970
|
+
'nodeId list as the boundary of the work. You do the analysis: look at every entry above, decide what ' +
|
|
1971
|
+
'actually needs doing, and then work in parallel rather than serially — dispatch one Agent per piece of ' +
|
|
1972
|
+
'work that needs doing, all in a single message, merging pieces that share a slice or chain so no two ' +
|
|
1973
|
+
`agents write to the same area. ${AGENT_BUDGET} Follow the "Standalone board-change turns" section of ` +
|
|
1974
|
+
'.agent-modeling-kit/CLAUDE.md, and if the model genuinely needs nothing right now, spawn nothing, change ' +
|
|
1975
|
+
'nothing and reply <promise>NOOP</promise>.';
|
|
1976
|
+
|
|
1885
1977
|
function buildStandaloneTurn() {
|
|
1886
|
-
const lines = [...observed.entries()].map(
|
|
1978
|
+
const lines = [...observed.entries()].map(
|
|
1979
|
+
([nodeId, entry]) =>
|
|
1980
|
+
`- ${nodeId}: ${[...entry.types].join(', ')} (${entry.count}×)${entry.maybeOwn ? ' — possibly your own earlier write' : ''}`,
|
|
1981
|
+
);
|
|
1887
1982
|
const header = [
|
|
1888
1983
|
'BOARD_CHANGE',
|
|
1889
1984
|
`board_id=${cfg.boardId}`,
|
|
1890
1985
|
`organization_id=${cfg.organizationId}`,
|
|
1891
1986
|
seqLo !== null ? `seq=${seqLo}${seqHi !== seqLo ? `..${seqHi}` : ''}` : null,
|
|
1892
1987
|
`events=${observedCount}`,
|
|
1988
|
+
`nodes=${observed.size}`,
|
|
1893
1989
|
].filter(Boolean).join(' ');
|
|
1990
|
+
return withSessionHeader(`${header}\nchanged:\n${lines.join('\n')}\n\n${STANDALONE_TASK}`);
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
// No events at all — the board has been sitting still. Same self-directed turn, with the
|
|
1994
|
+
// whole model as its subject instead of a changed corner of it.
|
|
1995
|
+
function buildIdleReviewTurn() {
|
|
1996
|
+
const header = [
|
|
1997
|
+
'BOARD_REVIEW',
|
|
1998
|
+
`board_id=${cfg.boardId}`,
|
|
1999
|
+
`organization_id=${cfg.organizationId}`,
|
|
2000
|
+
`idle_for=${Math.round(STANDALONE_IDLE_MS / 1000)}s`,
|
|
2001
|
+
].join(' ');
|
|
1894
2002
|
return withSessionHeader(
|
|
1895
|
-
`${header}\nchanged
|
|
1896
|
-
'Nobody asked you for this
|
|
1897
|
-
'
|
|
1898
|
-
'
|
|
1899
|
-
|
|
2003
|
+
`${header}\nchanged: nothing — the board has been quiet.\n\n` +
|
|
2004
|
+
'Nobody asked you for this and nothing changed: you are working on this board in the background, on ' +
|
|
2005
|
+
'your own initiative. Look over the model as a whole and decide what it still needs; for each piece of ' +
|
|
2006
|
+
'work that needs doing, dispatch one Agent, all in a single message so they run in parallel, exactly ' +
|
|
2007
|
+
`as the "Standalone board-change turns" section of .agent-modeling-kit/CLAUDE.md describes. ${AGENT_BUDGET} ` +
|
|
2008
|
+
'If the model needs nothing, spawn nothing, change nothing and reply <promise>NOOP</promise>.',
|
|
1900
2009
|
);
|
|
1901
2010
|
}
|
|
1902
2011
|
|
|
1903
|
-
|
|
1904
|
-
|
|
2012
|
+
let idleTimer = null;
|
|
2013
|
+
function armIdleReview() {
|
|
2014
|
+
if (!standalone || !STANDALONE_IDLE_MS) return;
|
|
2015
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2016
|
+
idleTimer = setTimeout(() => {
|
|
2017
|
+
idleTimer = null;
|
|
2018
|
+
// A buffer that filled meanwhile has its own timer — let that turn carry the work.
|
|
2019
|
+
if (observed.size || standaloneTimer || pending || draining) {
|
|
2020
|
+
armIdleReview();
|
|
2021
|
+
return;
|
|
2022
|
+
}
|
|
2023
|
+
dispatchStandaloneTurn({ idle: true }).catch((err) => log(`idle review dispatch error: ${err.message}`));
|
|
2024
|
+
}, STANDALONE_IDLE_MS);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
async function dispatchStandaloneTurn({ idle = false } = {}) {
|
|
2028
|
+
if (!idle && !observed.size) return;
|
|
1905
2029
|
// A direct message always outranks the agent's own initiative — re-arm instead of
|
|
1906
2030
|
// queueing behind the prompt lane, so the buffer just keeps collecting meanwhile.
|
|
1907
2031
|
if (pending || draining) {
|
|
1908
|
-
|
|
2032
|
+
if (idle) armIdleReview();
|
|
2033
|
+
else armStandaloneTurn(nextDelayMs());
|
|
1909
2034
|
return;
|
|
1910
2035
|
}
|
|
1911
|
-
const waitLeft =
|
|
2036
|
+
const waitLeft = minIntervalMs() - (Date.now() - lastStandaloneAt);
|
|
1912
2037
|
if (lastStandaloneAt && waitLeft > 0) {
|
|
1913
|
-
|
|
2038
|
+
if (idle) armIdleReview();
|
|
2039
|
+
else armStandaloneTurn(waitLeft);
|
|
1914
2040
|
return;
|
|
1915
2041
|
}
|
|
1916
|
-
const text = buildStandaloneTurn();
|
|
1917
|
-
|
|
2042
|
+
const text = idle ? buildIdleReviewTurn() : buildStandaloneTurn();
|
|
2043
|
+
if (idle) {
|
|
2044
|
+
log(`standalone review turn: board quiet for ${Math.round(STANDALONE_IDLE_MS / 1000)}s`);
|
|
2045
|
+
} else {
|
|
2046
|
+
const ownOnly = [...observed.values()].every((entry) => entry.maybeOwn);
|
|
2047
|
+
log(
|
|
2048
|
+
`standalone turn: ${observedCount} board event(s) on ${observed.size} node(s)` +
|
|
2049
|
+
`${ownOnly ? ' (all possibly own writes)' : ''}`,
|
|
2050
|
+
);
|
|
2051
|
+
}
|
|
1918
2052
|
observed.clear();
|
|
1919
2053
|
observedCount = 0;
|
|
1920
2054
|
seqLo = null;
|
|
1921
2055
|
seqHi = null;
|
|
2056
|
+
firstObservedAt = 0;
|
|
1922
2057
|
lastStandaloneAt = Date.now();
|
|
1923
2058
|
try {
|
|
1924
|
-
await runClaudeWarm(text);
|
|
2059
|
+
const result = await runClaudeWarm(text);
|
|
2060
|
+
// NOOP is the agent saying the board needs nothing — widen the floor so a finished
|
|
2061
|
+
// board isn't revisited at full rate. Any real contribution resets it.
|
|
2062
|
+
if (/NOOP/.test(String(result ?? ''))) {
|
|
2063
|
+
noopStreak += 1;
|
|
2064
|
+
log(`standalone turn: NOOP (${noopStreak} in a row — next no sooner than ${Math.round(minIntervalMs() / 1000)}s)`);
|
|
2065
|
+
} else {
|
|
2066
|
+
noopStreak = 0;
|
|
2067
|
+
}
|
|
1925
2068
|
} catch (err) {
|
|
1926
2069
|
log(`standalone turn failed: ${err.message}`);
|
|
2070
|
+
} finally {
|
|
2071
|
+
// Events that arrived while this turn ran are still in the buffer — give them a turn
|
|
2072
|
+
// of their own once the echo window has passed, instead of waiting for the next edit.
|
|
2073
|
+
if (observed.size) armStandaloneTurn(nextDelayMs());
|
|
2074
|
+
armIdleReview();
|
|
1927
2075
|
}
|
|
1928
2076
|
}
|
|
1929
2077
|
|
|
2078
|
+
armIdleReview();
|
|
2079
|
+
|
|
1930
2080
|
const channelName = `org:${cfg.organizationId}`;
|
|
1931
2081
|
const realtime = await createRealtimeAdapter(cfg, realtimeToken);
|
|
1932
2082
|
|
|
@@ -2458,7 +2608,8 @@ credentialFlags(program
|
|
|
2458
2608
|
.option('--ollama', 'Use ralph-ollama.js instead of the default Claude runner (build-kit stacks only)')
|
|
2459
2609
|
.option('--bash', 'Use the bash-only ralph.sh loop (build-kit stacks only, no realtime)')
|
|
2460
2610
|
.option('--modeling', 'Keep one Claude process warm across prompts instead of spawning a fresh one per task, for low-latency voice/live use. Runs from a modeling-kit install in this directory, or from the global install (~/.eventmodelers/kit) when there is none. Built into the CLI, not a per-project file.')
|
|
2461
|
-
.option('--standalone', 'Let the modeling agent
|
|
2611
|
+
.option('--standalone', 'Let the modeling agent work the board in the background, on its own initiative: on top of direct prompts it subscribes to the board\'s change channel (like the build agents do) and, whenever the board goes quiet after an edit — or has simply been idle for a while — it takes a turn nobody asked for. Changed nodes are a notification, not the task: it judges the model as a whole and fans the work out over parallel subagents, one per changed area (examples on a new node, a missing attribute along a chain, a screen, a question comment). Implies --modeling.')
|
|
2612
|
+
.option('--max-agents <n>', 'Cap how many subagents a self-directed --standalone turn may dispatch at once, to bound what an unattended agent can spend per turn. The agent merges work that shares a slice or chain first, then takes the most valuable pieces up to this many and leaves the rest for a later turn. 1 makes it do the single most valuable piece itself, without spawning anything. Default 5. Ignored without --standalone — prompt turns are one piece of work by definition.', '5')
|
|
2462
2613
|
.option('--global', 'Run the modeling agent from the global install (~/.eventmodelers/kit), initializing it on first use, and ignore any kit in this directory. This is also what --modeling/--standalone fall back to on their own when nothing is installed here — pass it explicitly to prefer the global install over a local one. Credentials come from the flags below, EVENTMODELERS_* env vars, or ~/.eventmodelers/boards/<board>.json, so nothing is written into the current directory.')
|
|
2463
2614
|
.option('--local', 'Skip platform config/credential lookup entirely and run the local-only loop (no board sync, no realtime agent) — even if .eventmodelers/config.json has credentials (build-kit stacks only)')
|
|
2464
2615
|
.option('--verbose', 'Log every tool call\'s full input (commands, skill args, file paths) and assistant reasoning text. Default is condensed, high-level per-step logging only.')
|
|
@@ -2466,6 +2617,13 @@ credentialFlags(program
|
|
|
2466
2617
|
.action(async (opts, command) => {
|
|
2467
2618
|
const globalOpts = command.optsWithGlobals();
|
|
2468
2619
|
const cwd = process.cwd();
|
|
2620
|
+
// Validated up front, before any runner is selected: a cost guard given as garbage
|
|
2621
|
+
// should fail on the spot, not once the loop is already up — and a cap passed where
|
|
2622
|
+
// nothing will read it is worth saying out loud rather than ignoring silently.
|
|
2623
|
+
const maxAgents = parseMaxAgents(opts.maxAgents);
|
|
2624
|
+
if (command.getOptionValueSource('maxAgents') === 'cli' && !opts.standalone) {
|
|
2625
|
+
console.log('ℹ️ --max-agents only applies to --standalone turns; ignoring it here.');
|
|
2626
|
+
}
|
|
2469
2627
|
// Both kit dirs can be installed side by side (e.g. running a build-kit and a
|
|
2470
2628
|
// modeling-kit agent from the same project). findInstalledKitDir only ever
|
|
2471
2629
|
// returns its first fixed-order match, which would silently prefer one stack
|
|
@@ -2529,7 +2687,7 @@ credentialFlags(program
|
|
|
2529
2687
|
const shown = relative(cwd, kitDir);
|
|
2530
2688
|
await new Promise((res) => process.stdout.write(`▶ Starting modeling loop (warm Claude process) for ${shown && !shown.startsWith('..') ? shown : kitDir}...\n\n`, res));
|
|
2531
2689
|
try {
|
|
2532
|
-
await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides);
|
|
2690
|
+
await runModeling(kitDir, projectDir, !!opts.verbose, !!opts.standalone, overrides, maxAgents);
|
|
2533
2691
|
} catch (err) {
|
|
2534
2692
|
console.error('[modeling] Fatal:', err);
|
|
2535
2693
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventmodelers/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.59",
|
|
4
4
|
"description": "Eventmodelers CLI — real-time Claude agent + skills for Claude Code, for any stack (Node, Supabase, Axon, Cratis, OpenCQRS, UmaDB, Kurrent, or modeling-only)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,10 +18,12 @@ every later turn just because a new prompt came in. The same applies to other on
|
|
|
18
18
|
setup; see step 2 below for `/connect`.
|
|
19
19
|
|
|
20
20
|
When the loop runs with `--standalone`, the CLI also subscribes to the board's own change
|
|
21
|
-
channel, so you get a second kind of turn on top of prompts: a **
|
|
22
|
-
first line starts with `BOARD_CHANGE`
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
channel, so you get a second kind of turn on top of prompts: a **self-directed turn**, whose
|
|
22
|
+
first line starts with `BOARD_CHANGE` (the board changed) or `BOARD_REVIEW` (nothing has
|
|
23
|
+
changed for a while) instead of `prompt_id=`. Nobody asked you for anything in those turns —
|
|
24
|
+
you are a background collaborator on this board: you judge the model as a whole, decide
|
|
25
|
+
what it needs, and fan the work out over parallel subagents. The listed changes are a
|
|
26
|
+
notification pointing at an area, never the task itself. They follow their own steps; see
|
|
25
27
|
"Standalone board-change turns" below. The session header's `standalone=on|off` tells you
|
|
26
28
|
whether this session gets them at all.
|
|
27
29
|
|
|
@@ -65,17 +67,30 @@ skip to "Standalone board-change turns" instead.
|
|
|
65
67
|
|
|
66
68
|
## Standalone board-change turns
|
|
67
69
|
|
|
68
|
-
Only in a `standalone=on` session.
|
|
70
|
+
Only in a `standalone=on` session. These turns come in two shapes, and both are
|
|
71
|
+
self-directed — nobody asked you for anything:
|
|
69
72
|
|
|
70
73
|
```
|
|
71
|
-
BOARD_CHANGE board_id=<uuid> organization_id=<uuid> seq=118..124 events=
|
|
74
|
+
BOARD_CHANGE board_id=<uuid> organization_id=<uuid> seq=118..124 events=9 nodes=3
|
|
72
75
|
changed:
|
|
73
|
-
- 9f3c…: node:created, node:changed
|
|
74
|
-
- a12b…: node:changed
|
|
76
|
+
- 9f3c…: node:created, node:changed (4×)
|
|
77
|
+
- a12b…: node:changed (2×) — possibly your own earlier write
|
|
78
|
+
- c771…: edge:added (3×)
|
|
75
79
|
```
|
|
76
80
|
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
```
|
|
82
|
+
BOARD_REVIEW board_id=<uuid> organization_id=<uuid> idle_for=900s
|
|
83
|
+
changed: nothing — the board has been quiet.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**The change list is a notification, not the work item.** It tells you that something
|
|
87
|
+
happened and which corner of the board to look at first — nothing more. It is not a task
|
|
88
|
+
list, not a boundary, and the last line of it is not "the" change to react to. A burst of
|
|
89
|
+
40 events on 6 nodes and a single `node:created` get the same treatment: you look at the
|
|
90
|
+
model, not at the event. A `BOARD_REVIEW` turn is the same job with no starting hint at all.
|
|
91
|
+
Nodes marked *possibly your own earlier write* are changes that landed while you were
|
|
92
|
+
working or just after — usually your own echo, so weigh them accordingly, but don't assume:
|
|
93
|
+
a human may well have been editing at the same time.
|
|
79
94
|
|
|
80
95
|
**There is no `prompt_id` in these turns — never call `/update-prompt-status` in one** (not
|
|
81
96
|
`IN_PROGRESS`, not `DONE`; the "exactly two calls per turn" rule is about prompt turns only).
|
|
@@ -83,46 +98,95 @@ There is nothing to sanitize either — a board change is not user text.
|
|
|
83
98
|
|
|
84
99
|
Steps:
|
|
85
100
|
|
|
86
|
-
1. **
|
|
87
|
-
or the REST equivalent) and
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
1. **Get the whole picture, not just the changed nodes.** Start at the listed nodes
|
|
102
|
+
(`mcp__eventmodelers__get_node`, or the REST equivalent) and widen out to what they sit
|
|
103
|
+
in — their cell, their slice, the chain they belong to, the timeline around them.
|
|
104
|
+
`mcp__eventmodelers__get_board_events` with the header's `seq` range tells you what the
|
|
105
|
+
change actually was when the node's current state doesn't make it obvious. Then judge the
|
|
106
|
+
board as a whole: run `/analyze-existing-model` once per session to get that picture and
|
|
107
|
+
keep it in mind across turns, refreshing it when a turn's changes invalidate it. On a
|
|
108
|
+
`BOARD_REVIEW` turn that model-wide picture *is* the starting point.
|
|
109
|
+
2. **Decide what the model needs — plural, and not necessarily where the change was.** List
|
|
110
|
+
the candidate contributions you can actually see evidence for, each with its own target
|
|
111
|
+
(node/cell/slice) and the skill that does it. A changed node is a reason to look; it is
|
|
112
|
+
not automatically the thing to work on, and work you spot two slices away counts just as
|
|
113
|
+
much. The usual candidates:
|
|
114
|
+
- an EVENT/COMMAND/READMODEL with fields but no example data → `/examples`
|
|
95
115
|
- a field added to one element that its chain neighbours are missing → `/attributes`
|
|
96
116
|
- an empty SCREEN node → `/html-screen`
|
|
97
|
-
- a timeline element that clearly should be sliced and isn't →
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
- a timeline element that clearly should be sliced and isn't →
|
|
118
|
+
`/eventmodeling-slicing-event-models`
|
|
119
|
+
- a gap or unhandled case that raises a real business question → one QUESTION comment via
|
|
120
|
+
`/handle-comment` with `action=place`
|
|
121
|
+
Nothing is a candidate when it's cosmetic (a node moved, resized or renamed), when the
|
|
122
|
+
target already has the thing you'd add, when it's inside something you yourself just
|
|
123
|
+
wrote, or when someone is visibly still working on it. An empty candidate list is a
|
|
124
|
+
perfectly good outcome — see step 8.
|
|
125
|
+
3. **Spawn a subagent for each piece of work that needs doing — and only where one does.** The
|
|
126
|
+
analysis in steps 1–2 is yours: you look at every entry in `changed:` yourself, in the
|
|
127
|
+
context of the model, and decide what (if anything) needs to happen. Then, for each
|
|
128
|
+
candidate that survived that judgment, dispatch one subagent via the `Agent` tool, **with
|
|
129
|
+
all of them in a single message** so they run in parallel. Entries that need nothing spawn
|
|
130
|
+
nothing; a turn where nothing needs doing spawns nothing at all and ends in a `NOOP`. What
|
|
131
|
+
you must never do is work the candidates one after another in your own turn, or pick one
|
|
132
|
+
out of five and drop the rest — nine events on three nodes that each need something are
|
|
133
|
+
three agents working at once. You analyse and coordinate; the agents do the work.
|
|
134
|
+
Each subagent prompt must be self-contained, because a subagent is a fresh session that
|
|
135
|
+
inherits none of this one's state:
|
|
136
|
+
- `token=`, `org=`, `baseUrl=` from this session's first message, and the instruction to
|
|
137
|
+
run `/connect` first;
|
|
138
|
+
- `board_id`, plus the exact target ids (`node_id`/`cellName`/`timelineId`/slice) it owns
|
|
139
|
+
— never "the node that changed";
|
|
140
|
+
- what you concluded in step 2: the specific piece of work, and enough of the surrounding
|
|
141
|
+
model for the agent to do it well;
|
|
142
|
+
- the one skill from the Skill Selection table to invoke, and the same rule that applies to
|
|
143
|
+
you: invoke the skill, don't substitute raw MCP calls;
|
|
144
|
+
- the standing constraints of step 4 and step 5 below.
|
|
145
|
+
**Stay inside the agent budget.** The session header carries `max_agents=<n>` (default 5)
|
|
146
|
+
and every self-directed turn restates it: that is the most Agents you may dispatch in one
|
|
147
|
+
turn, because a turn nobody asked for still costs money. Merge by area first (step 4) —
|
|
148
|
+
that's a correctness rule, not a way to fit the budget — and if more pieces are still left
|
|
149
|
+
than the cap allows, dispatch the most valuable ones and leave the rest; the board doesn't
|
|
150
|
+
forget, and a later turn will see them again. With `max_agents=1`, spawn nothing at all and
|
|
151
|
+
do the single most valuable piece yourself, inline.
|
|
152
|
+
**The decision stays with you.** A subagent is an executor, not a second judge: it carries
|
|
153
|
+
out the piece of work you decided on, on the target you named, and nothing else. It does
|
|
154
|
+
not re-open the question of whether the work is worth doing, does not widen its scope, and
|
|
155
|
+
does not go looking for other things on the board. If it finds the work doesn't apply after
|
|
156
|
+
all — the node already has what you'd add, someone is mid-edit — it reports that back to
|
|
157
|
+
you instead of substituting work of its own, and you decide what happens next.
|
|
158
|
+
Do the work inline yourself only when exactly one candidate survived and it is small (one
|
|
159
|
+
comment, one `/examples` call) — spawning a single agent for a single small thing is pure
|
|
160
|
+
overhead.
|
|
161
|
+
4. **Give every agent its own territory — merge before you dispatch, never split a slice.**
|
|
162
|
+
Two agents writing into the same node, chain or slice will clobber each other and the board
|
|
163
|
+
has no merge. So the mapping from step 3 is subject to one rule: candidates that live in
|
|
164
|
+
the same slice or the same chain are handled by **one** agent that owns that whole area,
|
|
165
|
+
with all of their work in its brief, not one agent each. That also keeps a big burst sane —
|
|
166
|
+
work on 30 changed nodes across 4 slices is 4 agents, well inside the default budget. Merge
|
|
167
|
+
first, then prioritize: a candidate is only ever deferred to a later turn because the budget
|
|
168
|
+
ran out, never because it was inconvenient to merge.
|
|
169
|
+
5. **Never undo or overwrite human work** — you and every agent you dispatch. You add to the
|
|
170
|
+
board; you don't delete, rename, restructure timelines, or move slice statuses on your own
|
|
171
|
+
initiative. If the right move would be destructive, post a comment saying so instead.
|
|
172
|
+
6. **If you already said it, don't say it again.** Before posting a comment — or having a
|
|
173
|
+
subagent post one — read the node's existing comments. An unresolved question already
|
|
174
|
+
there means that contribution is on the board.
|
|
175
|
+
7. **Write no progress entry.** A self-directed turn is modeling, not tracked progress —
|
|
115
176
|
nothing goes into `progress.txt` here (that file belongs to prompt turns, which answer to
|
|
116
177
|
someone who asked). Still promote anything reusable to `.agent-modeling-kit/AGENTS.md`
|
|
117
|
-
(same as step 9 of a prompt turn).
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
NOOP is a perfectly good outcome
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
178
|
+
(same as step 9 of a prompt turn), including anything a subagent reported back.
|
|
179
|
+
8. Reply `<promise>DONE</promise>`, naming what you dispatched and what each agent did, or —
|
|
180
|
+
when step 2 turned up nothing worth doing — change nothing at all and reply
|
|
181
|
+
`<promise>NOOP</promise>`. A NOOP is a perfectly good outcome, and the CLI widens the gap
|
|
182
|
+
before the next self-directed turn each time you answer one, so a finished board goes
|
|
183
|
+
quiet by itself. Don't manufacture work to avoid a NOOP.
|
|
184
|
+
|
|
185
|
+
Keep these turns finished within the turn: wait for the subagents you dispatched, don't leave
|
|
186
|
+
work trailing. Everything you and they write to the board comes back on this same channel as
|
|
187
|
+
another change; the CLI labels changes that arrive in that echo window rather than dropping
|
|
188
|
+
them, so you'll see your own writes listed on a later turn — recognize them and don't rework
|
|
189
|
+
them.
|
|
126
190
|
|
|
127
191
|
## Skill Selection
|
|
128
192
|
|