@eventmodelers/cli 1.0.163 → 2.0.2
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 +48 -36
- package/RELEASE_NOTES.md +20 -2
- package/cli.js +774 -247
- package/lib/adapters/spec-kitty-adapter.js +3 -7
- package/lib/build-kits.js +23 -0
- package/lib/global-agent-id.js +45 -0
- package/lib/modeling-local-ai.js +4 -2
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/README.md +4 -19
- package/shared/build-kit/lib/agent-lock.js +75 -0
- package/shared/build-kit/lib/agent-name.js +27 -0
- package/shared/build-kit/lib/event-prune.js +58 -0
- package/shared/build-kit/lib/events-view.js +134 -0
- package/shared/build-kit/lib/local-ai-agent.js +14 -26
- package/shared/build-kit/lib/mcp-register.js +17 -1
- package/shared/build-kit/lib/prompt-journal.js +191 -0
- package/shared/build-kit/lib/prompt-replay.js +35 -0
- package/shared/build-kit/lib/ralph.js +218 -169
- package/shared/build-kit/lib/slice-curation.js +40 -0
- package/shared/build-kit/lib/slice-events.js +93 -0
- package/shared/build-kit/lib/slice-git.js +86 -0
- package/shared/build-kit/lib/slice-learnings.js +197 -0
- package/shared/build-kit/lib/slice-projection.js +101 -0
- package/shared/build-kit/lib/slice-run.js +181 -0
- package/shared/build-kit/lib/slice-state.js +184 -0
- package/shared/build-kit/lib/slice-tasks.js +35 -0
- package/shared/build-kit/ralph-claude.js +4 -2
- package/shared/build-kit/ralph-exec.js +4 -2
- package/shared/build-kit/ralph-local-ai.js +5 -3
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +17 -4
- package/shared/skills/request-feedback/SKILL.md +1 -2
- package/shared/skills/ui-contract/SKILL.md +7 -5
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/axon/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/axon/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/blank/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/blank/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +20 -37
- package/stacks/blank/templates/build-kit/lib/prompt.md +33 -42
- package/stacks/classic/templates/.claude/skills/build-automation/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/classic/templates/.claude/skills/build-state-view/SKILL.md +2 -2
- package/stacks/classic/templates/.claude/skills/record-evidence/SKILL.md +2 -2
- package/stacks/classic/templates/build-kit/CLAUDE.md +9 -8
- package/stacks/classic/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +20 -38
- package/stacks/classic/templates/build-kit/lib/prompt.md +33 -42
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +7 -7
- package/stacks/kurrent/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/detect-model-drift/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-core-rules/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/eventmodeling-elaborating-scenarios/SKILL.md +7 -3
- package/stacks/modeling-kit/templates/.claude/skills/wdyt/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +3 -4
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +18 -28
- package/stacks/modeling-kit/templates/kit/README.md +1 -1
- package/stacks/modeling-kit/templates/kit/lib/config.js +1 -1
- package/stacks/node/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/node/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/node/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +7 -7
- package/stacks/opencqrs/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +32 -41
- package/stacks/react/templates/.claude/skills/build-state-change/SKILL.md +1 -1
- package/stacks/react/templates/.claude/skills/build-state-view/SKILL.md +1 -1
- package/stacks/react/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +25 -42
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +47 -43
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/supabase/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/supabase/templates/build-kit/lib/prompt.md +18 -41
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +80 -8
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +66 -13
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +8 -7
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +25 -42
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +48 -44
- package/stacks/umadb/templates/build-kit/CLAUDE.md +7 -6
- package/stacks/umadb/templates/build-kit/lib/AGENT.md +6 -7
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +20 -42
- package/stacks/umadb/templates/build-kit/lib/prompt.md +32 -41
- package/shared/bridge/spec-kitty/bridge-spec-kitty-specify/SKILL.md +0 -125
- package/shared/bridge/spec-kitty/bridge-spec-kitty-tasks/SKILL.md +0 -95
- package/shared/build-kit/lib/agent.sh +0 -20
- package/shared/build-kit/ralph.sh +0 -111
- package/shared/build-kit/realtime-agent.js +0 -20
- package/stacks/bridge/templates/bridge/lib/AGENT.md +0 -47
- package/stacks/bridge/templates/bridge/lib/prompt.md +0 -111
- package/stacks/bridge/templates/bridge/ralph-claude.js +0 -50
- package/stacks/bridge/templates/bridge/ralph-hook.js +0 -103
- package/stacks/bridge/templates/bridge/ralph-local-ai.js +0 -43
- package/stacks/bridge/templates/bridge/ralph-static.js +0 -90
|
@@ -27,8 +27,6 @@ node .build-kit/ralph-claude.js /path/to/project
|
|
|
27
27
|
| `ralph-claude.js` | Runs the full loop using Claude Code as the executor |
|
|
28
28
|
| `ralph-local-ai.js` | Runs the full loop using a local/self-hosted model (Ollama, vLLM, LM Studio, llama.cpp) |
|
|
29
29
|
| `ralph-exec.js` | Runs the full loop handing each prompt to an external agent command (Codex CLI, OpenCode, …) |
|
|
30
|
-
| `ralph.sh` | Shell-based loop — alternative to the JS entry points |
|
|
31
|
-
| `realtime-agent.js` | Standalone realtime agent — only needed to run it in a separate terminal |
|
|
32
30
|
|
|
33
31
|
**Internals** (`lib/`):
|
|
34
32
|
|
|
@@ -36,16 +34,15 @@ node .build-kit/ralph-claude.js /path/to/project
|
|
|
36
34
|
|------|---------|
|
|
37
35
|
| `lib/ralph.js` | Shared library — realtime agent + loop logic; imported by the entry points |
|
|
38
36
|
| `lib/local-ai-agent.js` | Local-AI executor — called by `ralph-local-ai.js`, can also run manually |
|
|
39
|
-
| `lib/agent.sh` | Thin shell wrapper around `claude` — called by `ralph.sh` |
|
|
40
37
|
| `lib/prompt.md` | Phase 1 prompt: tells Claude how to load a slice from the board |
|
|
41
38
|
| `lib/backend-prompt.md` | Phase 2 prompt: tells Claude how to build a planned slice |
|
|
42
39
|
| `lib/AGENT.md` | Agent instructions included in Claude's context |
|
|
43
40
|
|
|
44
41
|
## How it works
|
|
45
42
|
|
|
46
|
-
**Phase 1** — triggered when
|
|
47
|
-
- The realtime agent
|
|
48
|
-
- The loop
|
|
43
|
+
**Phase 1** — triggered when the task queue has entries (the queue is the event log in `.build-kit/events/`):
|
|
44
|
+
- The realtime agent queues a task (`task-queued`) each time a `slice:changed` event arrives from the board; a newer change to the same slice replaces its queued task
|
|
45
|
+
- The loop hands the oldest one to Claude (or a local model) with `prompt.md` and completes it (`task-done`) when the turn ends
|
|
49
46
|
- Claude loads the slice data and updates `.slices/`
|
|
50
47
|
|
|
51
48
|
**Phase 2** — triggered when any file in `.slices/` contains `"status": "Planned"`:
|
|
@@ -55,18 +52,6 @@ node .build-kit/ralph-claude.js /path/to/project
|
|
|
55
52
|
|
|
56
53
|
Both phases run in a continuous loop with a 3-second idle sleep. The realtime agent runs concurrently in the same process.
|
|
57
54
|
|
|
58
|
-
## Running the realtime agent separately
|
|
59
|
-
|
|
60
|
-
If you want the board subscription in one terminal and the Claude loop in another:
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
# Terminal 1 — realtime agent only
|
|
64
|
-
node .build-kit/realtime-agent.js
|
|
65
|
-
|
|
66
|
-
# Terminal 2 — loop only (poll tasks.json without the realtime subscription)
|
|
67
|
-
.build-kit/ralph.sh
|
|
68
|
-
```
|
|
69
|
-
|
|
70
55
|
## Local-AI configuration
|
|
71
56
|
|
|
72
57
|
`ralph-local-ai.js` drives any local or self-hosted model that can do tool calling.
|
|
@@ -78,7 +63,7 @@ Claude (`ralph-claude.js`) stays the default runner — this is opt-in.
|
|
|
78
63
|
Planned. That is by design, not a bug:
|
|
79
64
|
|
|
80
65
|
- **Planned slices are never handed to it.** `ralph-local-ai.js` gives `startRalph` no
|
|
81
|
-
`onPlannedSlice`, so the loop only feeds it
|
|
66
|
+
`onPlannedSlice`, so the loop only feeds it queued tasks.
|
|
82
67
|
- **It has no coding tools.** `lib/local-ai-agent.js` exposes only the board MCP tools:
|
|
83
68
|
no file read/write, no shell, no git, no test run.
|
|
84
69
|
- **Its system prompt forbids file and shell access.** Such a request is answered with
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// One agent id, one live process. An agent keeps its stable id (see ensureAgentId); a second process that would
|
|
2
|
+
// share it — two `run`s in one project, two standalone runs from one repo — gets a fresh id instead. Sharing one
|
|
3
|
+
// would make them fight over a single heartbeat row and a single chat, and the second one's start-up recovery
|
|
4
|
+
// would hand back the first one's prompts.
|
|
5
|
+
//
|
|
6
|
+
// The id is claimed with an exclusive lock file (`<id>.lock`, holding the pid) that the process removes on exit. It
|
|
7
|
+
// is written complete under a private name and then hard-linked into place, so a competitor never finds it empty
|
|
8
|
+
// and mistakes a lock being taken for one left over.
|
|
9
|
+
// A lock left by a process that is gone — a crash, kill -9 — is taken over. A lock problem never stops an agent:
|
|
10
|
+
// if locking is impossible the stable id is simply kept.
|
|
11
|
+
|
|
12
|
+
import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
|
|
13
|
+
import { randomUUID } from 'crypto';
|
|
14
|
+
import { join } from 'path';
|
|
15
|
+
import { isProcessAlive } from './slice-run.js';
|
|
16
|
+
|
|
17
|
+
const answers = new Map(); // lock file path → the answer given in this process, so asking twice is one decision
|
|
18
|
+
const owned = new Set(); // lock files this process holds
|
|
19
|
+
let exitHooked = false;
|
|
20
|
+
|
|
21
|
+
function release() {
|
|
22
|
+
for (const path of owned) {
|
|
23
|
+
try {
|
|
24
|
+
if (JSON.parse(readFileSync(path, 'utf-8')).pid === process.pid) rmSync(path, { force: true });
|
|
25
|
+
} catch { /* already gone */ }
|
|
26
|
+
}
|
|
27
|
+
owned.clear();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// true: the lock is ours; false: a live process of another pid holds it.
|
|
31
|
+
function lock(path, pid, isAlive) {
|
|
32
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
33
|
+
try {
|
|
34
|
+
const mine = `${path}.${pid}.${randomUUID()}.tmp`;
|
|
35
|
+
writeFileSync(mine, JSON.stringify({ pid, startedAt: new Date().toISOString() }));
|
|
36
|
+
try {
|
|
37
|
+
linkSync(mine, path); // EEXIST when someone holds it — atomically, and never half written
|
|
38
|
+
} finally {
|
|
39
|
+
rmSync(mine, { force: true });
|
|
40
|
+
}
|
|
41
|
+
owned.add(path);
|
|
42
|
+
if (!exitHooked) { exitHooked = true; process.on('exit', release); }
|
|
43
|
+
return true;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code !== 'EEXIST') return true; // cannot lock here (permissions, odd filesystem): do not get in the way
|
|
46
|
+
let holder = null;
|
|
47
|
+
try { holder = JSON.parse(readFileSync(path, 'utf-8')).pid; } catch { /* unreadable: left over */ }
|
|
48
|
+
if (holder === pid) return true;
|
|
49
|
+
if (holder !== null && isAlive(holder)) return false;
|
|
50
|
+
rmSync(path, { force: true }); // left over by a process that is gone — take it over
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `stableId` is the id this agent keeps. Returns { agentId, fellBack }: the stable id when this process got the
|
|
57
|
+
// lock, else a fresh one. Decided once per process and lock directory.
|
|
58
|
+
export function acquireAgentId({ lockDir, stableId, mint = randomUUID, isAlive = isProcessAlive, pid = process.pid }) {
|
|
59
|
+
const path = join(lockDir, `${stableId}.lock`);
|
|
60
|
+
if (answers.has(path)) return answers.get(path);
|
|
61
|
+
let taken = true;
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(lockDir, { recursive: true });
|
|
64
|
+
taken = lock(path, pid, isAlive);
|
|
65
|
+
} catch { /* cannot lock here: keep the stable id */ }
|
|
66
|
+
const answer = taken ? { agentId: stableId, fellBack: false } : { agentId: mint(), fellBack: true };
|
|
67
|
+
answers.set(path, answer);
|
|
68
|
+
return answer;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Releases this process's locks and forgets what it decided — for tests, and a clean shutdown.
|
|
72
|
+
export function forgetAgentLocks() {
|
|
73
|
+
release();
|
|
74
|
+
answers.clear();
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The name an agent shows on the board when nobody gave it one: the repository it runs in and what it is
|
|
2
|
+
// ("cart-service (build)"). Derived on every start, so it is stable for as long as the repo is, and never
|
|
3
|
+
// written into the project's config — `agentName` there (init --name) and `run --name` still win.
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'child_process';
|
|
6
|
+
import { basename } from 'path';
|
|
7
|
+
|
|
8
|
+
// `git@github.com:acme/cart-service.git`, `https://host/acme/cart-service/`, `ssh://git@host:2222/a/b.git` →
|
|
9
|
+
// the last path segment without `.git`.
|
|
10
|
+
function repoNameFromRemote(url) {
|
|
11
|
+
const last = url.trim().replace(/\/+$/, '').split(/[/:]/).pop() ?? '';
|
|
12
|
+
return last.replace(/\.git$/, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function defaultAgentName(projectDir, agentType = 'BUILD') {
|
|
16
|
+
let repo = '';
|
|
17
|
+
try {
|
|
18
|
+
repo = repoNameFromRemote(execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: projectDir, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
19
|
+
} catch { /* no remote, or not a git repo */ }
|
|
20
|
+
return `${repo || basename(projectDir)} (${agentType.toLowerCase()})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// A second agent in the same repo gets a fresh id (see agent-lock.js); its default name says which instance it is,
|
|
24
|
+
// so the board does not show two rows of the same name.
|
|
25
|
+
export function withInstanceSuffix(name, agentId) {
|
|
26
|
+
return `${name} · ${String(agentId).slice(0, 4)}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Pruning the event log. The log only grows, and most of it is the prompt journal — a received/finished pair per
|
|
2
|
+
// board prompt, plus every tool call of a `--verbose` run — which nothing reads once the prompt is finished. What
|
|
3
|
+
// the state is derived from (claims, settles, learnings, the task queue, prompts still open) is never touched.
|
|
4
|
+
// Before anything is deleted the state is folded from what would be left and compared with the state folded from
|
|
5
|
+
// the whole log; a difference aborts the prune.
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
|
|
8
|
+
import { isDeepStrictEqual } from 'util';
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { EVENTS_DIR, INDEX_FILE, readEvents } from './slice-events.js';
|
|
11
|
+
import { fold } from './slice-state.js';
|
|
12
|
+
|
|
13
|
+
export const PRUNE_EVERY = 1000;
|
|
14
|
+
const ASKED_FILE = '.prune-asked'; // inside events/, which readEvents never lists and the kit's git rules already cover
|
|
15
|
+
|
|
16
|
+
const JOURNAL_TYPES = new Set(['prompt-received', 'prompt-finished', 'prompt-action', 'tool-call']);
|
|
17
|
+
|
|
18
|
+
// The events of prompts that have finished (and of nothing else).
|
|
19
|
+
export function prunableEvents(events) {
|
|
20
|
+
const finished = new Set(events.filter((e) => e?.type === 'prompt-finished').map((e) => e.prompt));
|
|
21
|
+
return events.filter((e) => e && JOURNAL_TYPES.has(e.type) && finished.has(e.type === 'prompt-received' ? e.prompt?.id : e.prompt));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Deletes them: their files, then their lines from the index (rewritten whole and renamed into place). Returns
|
|
25
|
+
// { removed, kept }; removes nothing when there is nothing to remove, and throws without touching the log when
|
|
26
|
+
// the state folded from the rest would differ.
|
|
27
|
+
export function pruneLog(kitDir) {
|
|
28
|
+
const events = readEvents(kitDir);
|
|
29
|
+
const doomed = new Set(prunableEvents(events).map((e) => e.id));
|
|
30
|
+
if (!doomed.size) return { removed: 0, kept: events.length };
|
|
31
|
+
const rest = events.filter((e) => !doomed.has(e.id));
|
|
32
|
+
if (!isDeepStrictEqual(fold(rest), fold(events))) throw new Error('pruning would change the state the log folds to — nothing was deleted');
|
|
33
|
+
|
|
34
|
+
for (const id of doomed) rmSync(join(kitDir, EVENTS_DIR, `${id}.json`), { force: true });
|
|
35
|
+
const index = join(kitDir, INDEX_FILE);
|
|
36
|
+
const lines = readFileSync(index, 'utf-8').split('\n').filter((l) => l.trim() && !doomed.has(l.trim()));
|
|
37
|
+
const tmp = `${index}.tmp`;
|
|
38
|
+
writeFileSync(tmp, lines.length ? `${lines.join('\n')}\n` : '', 'utf-8');
|
|
39
|
+
renameSync(tmp, index);
|
|
40
|
+
return { removed: doomed.size, kept: rest.length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Asked when the log has reached a multiple of PRUNE_EVERY it has not been asked at yet — 1000, then 2000, … — so a
|
|
44
|
+
// "no" is not repeated on every start. `remember` records the size the answer was given at.
|
|
45
|
+
const askedPath = (kitDir) => join(kitDir, EVENTS_DIR, ASKED_FILE);
|
|
46
|
+
const bucket = (n) => Math.floor(n / PRUNE_EVERY);
|
|
47
|
+
|
|
48
|
+
export function shouldOfferPrune(kitDir, count) {
|
|
49
|
+
if (bucket(count) < 1) return false;
|
|
50
|
+
let asked = 0;
|
|
51
|
+
try { asked = Number(readFileSync(askedPath(kitDir), 'utf-8')) || 0; } catch { /* never asked */ }
|
|
52
|
+
return bucket(count) > bucket(asked);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function rememberPruneAnswer(kitDir, count) {
|
|
56
|
+
if (!existsSync(join(kitDir, EVENTS_DIR))) mkdirSync(join(kitDir, EVENTS_DIR), { recursive: true });
|
|
57
|
+
writeFileSync(askedPath(kitDir), String(count), 'utf-8');
|
|
58
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The slice-state event log, decoded for a terminal (`eventmodelers events`): one plain-text line per event, the
|
|
2
|
+
// filters that go with it, and a summary of the state the log folds to. Colour is the command's business.
|
|
3
|
+
|
|
4
|
+
const short = (id) => (id ? String(id).slice(0, 8) : '');
|
|
5
|
+
const oneLine = (text, max) => {
|
|
6
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
7
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
8
|
+
};
|
|
9
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
10
|
+
|
|
11
|
+
// slice id → title, from what the log itself knows (a claim records the slice's title).
|
|
12
|
+
export function sliceTitles(events) {
|
|
13
|
+
const titles = {};
|
|
14
|
+
for (const e of events) if (e.type === 'claimed' && e.slice && e.title) titles[e.slice] = e.title;
|
|
15
|
+
return titles;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// How long a call took, and that it failed — nothing for an event without a duration.
|
|
19
|
+
const took = (e) => (typeof e.durationMs === 'number' ? ` (${e.durationMs < 1000 ? `${e.durationMs}ms` : `${(e.durationMs / 1000).toFixed(1)}s`}${e.error ? ', failed' : ''})` : '');
|
|
20
|
+
|
|
21
|
+
// [subject, detail] — what the event is about, and what it says.
|
|
22
|
+
function describe(e) {
|
|
23
|
+
const tags = e.tags?.length ? ` ${e.tags.map((t) => `#${t}`).join(' ')}` : '';
|
|
24
|
+
switch (e.type) {
|
|
25
|
+
case 'board-observed': case 'agent-recorded': case 'settled':
|
|
26
|
+
return [e.slice, e.status ?? ''];
|
|
27
|
+
case 'claimed': {
|
|
28
|
+
const tree = e.cleanTree === true ? 'clean tree' : e.cleanTree === false ? 'dirty tree' : 'not a git tree';
|
|
29
|
+
return [e.slice, `pid ${e.pid}, ${tree}${e.base ? `, ${e.base.branch}@${String(e.base.head).slice(0, 8)}` : ''}${e.ticketNumber ? `, ticket ${e.ticketNumber}` : ''}${e.assignee ? `, assignee ${e.assignee}` : ''}`];
|
|
30
|
+
}
|
|
31
|
+
case 'claim-confirmed':
|
|
32
|
+
return [e.slice, ''];
|
|
33
|
+
case 'released':
|
|
34
|
+
return [e.slice, e.reason ?? ''];
|
|
35
|
+
case 'attempt-failed': {
|
|
36
|
+
const files = String(e.changed ?? '').split('\n').filter((l) => l.trim()).length;
|
|
37
|
+
const parts = [e.reason, e.status ? `back to ${e.status}` : '', files ? plural(files, 'file') : '', e.branch ?? ''].filter(Boolean);
|
|
38
|
+
return [e.slice, `${parts.join(', ')}${e.lastReply ? `: ${oneLine(e.lastReply, 80)}` : ''}`];
|
|
39
|
+
}
|
|
40
|
+
case 'blocked':
|
|
41
|
+
return [e.slice, oneLine(e.reason, 120)];
|
|
42
|
+
case 'task-queued':
|
|
43
|
+
return [e.payload?.sliceId, e.payload?.sliceStatus ?? ''];
|
|
44
|
+
case 'task-done':
|
|
45
|
+
return [e.task, ''];
|
|
46
|
+
case 'learning-recorded':
|
|
47
|
+
return ['', `[${e.topic ?? 'General'}] ${oneLine(e.text, 160)} (${e.scope ?? 'repo'}${tags})`];
|
|
48
|
+
case 'learning-retracted':
|
|
49
|
+
return [e.learning, e.reason ? oneLine(e.reason, 120) : ''];
|
|
50
|
+
case 'learning-merged':
|
|
51
|
+
return ['', `${plural(e.sources?.length ?? e.learnings?.length ?? 0, 'learning')} → ${oneLine(e.text, 120)}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
|
|
52
|
+
case 'learnings-curated':
|
|
53
|
+
return ['', ''];
|
|
54
|
+
case 'prompt-received':
|
|
55
|
+
return [e.prompt?.id, `"${oneLine(e.prompt?.text, 80)}"${e.prompt?.priority ? ', priority' : ''}, pid ${e.pid}`];
|
|
56
|
+
case 'prompt-action':
|
|
57
|
+
return [e.prompt, `${e.action ?? ''}${e.detail ? ` ${oneLine(e.detail, 140)}` : ''}${took(e)}`];
|
|
58
|
+
case 'tool-call':
|
|
59
|
+
return [e.prompt, `${oneLine(e.detail ?? e.tool, 140)}${took(e)}`];
|
|
60
|
+
case 'prompt-finished':
|
|
61
|
+
return [e.prompt, `${e.outcome ?? ''}${e.reason ? ` — ${oneLine(e.reason, 80)}` : ''}`];
|
|
62
|
+
default: {
|
|
63
|
+
const { type, id, at, meta, slice, ...rest } = e;
|
|
64
|
+
return [slice, oneLine(JSON.stringify(rest), 160)];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// One line: date and time (UTC, as logged), type, subject (a slice with its title when known), detail, and the first four characters of the
|
|
70
|
+
// agent that wrote it with its type (build or modeling) — a log shared by several agents says who did what.
|
|
71
|
+
export function formatEvent(e, { titles = {} } = {}) {
|
|
72
|
+
const time = typeof e.at === 'string' && e.at.length >= 19 ? `${e.at.slice(0, 10)} ${e.at.slice(11, 19)}` : '-'.repeat(19);
|
|
73
|
+
const [rawSubject, detail] = describe(e);
|
|
74
|
+
const title = titles[rawSubject];
|
|
75
|
+
const subject = rawSubject ? `${short(rawSubject)}${title ? ` "${title}"` : ''}` : '';
|
|
76
|
+
const agent = [e.meta?.agent_id ? String(e.meta.agent_id).slice(0, 4) : '', String(e.meta?.agent_type ?? '').toLowerCase()].filter(Boolean).join(' ');
|
|
77
|
+
return [time, String(e.type).padEnd(18), subject, detail, agent].filter((p, i) => i < 2 || p).join(' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// What an event carries beyond its line, indented under it: every field but the ones the line already shows
|
|
81
|
+
// (type, id, time, who). Empty when there is nothing more to say.
|
|
82
|
+
export function formatPayload(e) {
|
|
83
|
+
const { type, id, at, meta, ...rest } = e;
|
|
84
|
+
if (!Object.keys(rest).length) return '';
|
|
85
|
+
return JSON.stringify(rest, null, 2).split('\n').map((l) => ` ${l}`).join('\n');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The kit's first look at each slice: its first `board-observed` event. A fresh kit records every slice already on
|
|
89
|
+
// the board this way, which says nothing happened — the ids to leave out of the default view of the log.
|
|
90
|
+
export function baselineIds(events) {
|
|
91
|
+
const seen = new Set();
|
|
92
|
+
const ids = new Set();
|
|
93
|
+
for (const e of events) {
|
|
94
|
+
if (e.type !== 'board-observed' || !e.slice || seen.has(e.slice)) continue;
|
|
95
|
+
seen.add(e.slice);
|
|
96
|
+
ids.add(e.id);
|
|
97
|
+
}
|
|
98
|
+
return ids;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// types / slice (an id prefix or part of a title) / tag / board (an id prefix — a global kit's log mixes boards) narrow
|
|
102
|
+
// the log; omit is a set of event ids to leave out (see baselineIds); last keeps the final N that remain.
|
|
103
|
+
export function filterEvents(events, { types, slice, tag, board, last, omit } = {}, titles = {}) {
|
|
104
|
+
let out = omit?.size ? events.filter((e) => !omit.has(e.id)) : events;
|
|
105
|
+
if (board) out = out.filter((e) => String(e.meta?.board_id ?? '').toLowerCase().startsWith(board.toLowerCase()));
|
|
106
|
+
if (types?.length) out = out.filter((e) => types.includes(e.type));
|
|
107
|
+
if (slice) {
|
|
108
|
+
const wanted = slice.toLowerCase();
|
|
109
|
+
out = out.filter((e) => {
|
|
110
|
+
const id = e.slice ?? e.payload?.sliceId;
|
|
111
|
+
return !!id && (String(id).toLowerCase().startsWith(wanted) || String(titles[id] ?? '').toLowerCase().includes(wanted));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (tag) out = out.filter((e) => e.tags?.includes(tag));
|
|
115
|
+
return last ? out.slice(-last) : out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// What the log folds to: slices (status, holder, attempts), the queue, prompts in hand, learnings.
|
|
119
|
+
export function summarize(state, titles = {}) {
|
|
120
|
+
const lines = [];
|
|
121
|
+
const slices = Object.entries(state.slices);
|
|
122
|
+
if (!slices.length) lines.push('no slices in the log');
|
|
123
|
+
for (const [id, s] of slices) {
|
|
124
|
+
const claim = s.claim ? ` claimed by pid ${s.claim.pid}${s.claim.confirmed ? '' : ' (unconfirmed)'}` : '';
|
|
125
|
+
const name = titles[id] ?? s.claim?.title;
|
|
126
|
+
const title = name ? ` "${name}"` : '';
|
|
127
|
+
const attempts = s.attempts ? ` ${plural(s.attempts, 'failed attempt')}` : '';
|
|
128
|
+
const blocked = s.blocked ? ` blocked: ${oneLine(s.blocked.reason, 80)}` : '';
|
|
129
|
+
lines.push(`${short(id)}${title} ${s.status ?? 'unknown'}${claim}${attempts}${blocked}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(`${plural(state.tasks.length, 'task')} waiting · ${plural(state.prompts.length, 'prompt')} open · ${plural(state.learnings.length, 'learning')}`);
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
@@ -6,16 +6,17 @@
|
|
|
6
6
|
// ollama — Ollama's native POST /api/chat
|
|
7
7
|
// openai — the OpenAI-compatible POST /v1/chat/completions that vLLM, LM Studio,
|
|
8
8
|
// llama.cpp-server, TGI, SGLang (and hosted gateways) all speak
|
|
9
|
-
// Everything above the transport — the MCP tool loop, the
|
|
9
|
+
// Everything above the transport — the MCP tool loop, the
|
|
10
10
|
// security prompt — is identical for both, which is why this is one file and not
|
|
11
11
|
// one kit per vendor.
|
|
12
12
|
//
|
|
13
13
|
// Usage: node local-ai-agent.js [model]
|
|
14
14
|
// LOCAL_AI_TARGET=vllm node local-ai-agent.js
|
|
15
15
|
// LOCAL_AI_URL=http://gpu-box:8000/v1 LOCAL_AI_MODEL=Qwen/Qwen3-8B node local-ai-agent.js
|
|
16
|
-
//
|
|
16
|
+
// The run loop (lib/ralph.js) picks the oldest queued task and hands it over as the prompt on stdin — one task
|
|
17
|
+
// per run, completed by the loop when this process exits cleanly. There is no queue file to read.
|
|
17
18
|
|
|
18
|
-
import { readFileSync
|
|
19
|
+
import { readFileSync } from 'fs';
|
|
19
20
|
import { resolve, dirname } from 'path';
|
|
20
21
|
import { fileURLToPath } from 'url';
|
|
21
22
|
|
|
@@ -254,28 +255,15 @@ async function runAgent(userPrompt, boardId) {
|
|
|
254
255
|
return 'Max tool iterations reached.';
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
async function
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const blocked = tasks.filter(t => t.blocked === true || t.blockedBy?.length > 0);
|
|
263
|
-
if (blocked.length > 0) {
|
|
264
|
-
console.error(`[local-ai] removing ${blocked.length} blocked task(s): ${blocked.map(t => t.id).join(', ')}`);
|
|
265
|
-
tasks = tasks.filter(t => !blocked.includes(t));
|
|
266
|
-
writeFileSync(tasksPath, JSON.stringify(tasks, null, 2));
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const task = tasks[0];
|
|
270
|
-
if (!task) return;
|
|
271
|
-
|
|
272
|
-
console.error(`[local-ai] task=${task.id} prompts=${task.prompts.length}`);
|
|
273
|
-
|
|
274
|
-
for (const p of task.prompts) {
|
|
275
|
-
console.log(await runAgent(p.prompt, p.board_id || defaultBoardId));
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
writeFileSync(tasksPath, JSON.stringify(tasks.slice(1), null, 2));
|
|
258
|
+
async function readStdin() {
|
|
259
|
+
let text = '';
|
|
260
|
+
for await (const chunk of process.stdin) text += chunk;
|
|
261
|
+
return text;
|
|
279
262
|
}
|
|
280
263
|
|
|
281
|
-
await
|
|
264
|
+
const prompt = await readStdin();
|
|
265
|
+
if (!prompt.trim()) {
|
|
266
|
+
console.error('[local-ai] no prompt on stdin — this agent is started by the run loop with the task as its prompt');
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
console.log(await runAgent(prompt, defaultBoardId));
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// loop (ralph-exec.js).
|
|
4
4
|
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
6
7
|
import { homedir } from 'os';
|
|
7
8
|
import { dirname, join } from 'path';
|
|
8
9
|
|
|
@@ -12,6 +13,18 @@ export function shellQuote(s) {
|
|
|
12
13
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// Older OpenCode releases have no `run --standalone`; passing it makes `opencode run` print its
|
|
17
|
+
// usage and exit 1, so every turn fails. Ask the installed binary once. Overridable for tests.
|
|
18
|
+
let standaloneSupported = null;
|
|
19
|
+
export function opencodeSupportsStandalone() {
|
|
20
|
+
if (standaloneSupported === null) {
|
|
21
|
+
const r = spawnSync('opencode', ['run', '--help'], { encoding: 'utf8' });
|
|
22
|
+
standaloneSupported = /--standalone\b/.test(`${r.stdout || ''}${r.stderr || ''}`);
|
|
23
|
+
}
|
|
24
|
+
return standaloneSupported;
|
|
25
|
+
}
|
|
26
|
+
export function setOpencodeStandaloneSupport(value) { standaloneSupported = value; }
|
|
27
|
+
|
|
15
28
|
// Adds or refreshes one key in a project JSON config, leaving everything else in it alone. A file
|
|
16
29
|
// that isn't plain JSON (a JSONC one with comments) is not rewritten — returns false instead, and
|
|
17
30
|
// the caller says what to add by hand.
|
|
@@ -50,7 +63,10 @@ const MCP_REGISTRATIONS = {
|
|
|
50
63
|
// A plain `opencode run` hands the turn to opencode's shared background service, which was
|
|
51
64
|
// started with some other env: {env:EVENTMODELERS_TOKEN} resolves to nothing there and the
|
|
52
65
|
// server refuses every call. --standalone runs a private server that inherits this one's env.
|
|
53
|
-
|
|
66
|
+
// A binary without the flag runs the turn in-process already, so the flag is dropped, not added.
|
|
67
|
+
const supported = opencodeSupportsStandalone();
|
|
68
|
+
const standalone = !supported ? command.replace(/\s--standalone\b/, '')
|
|
69
|
+
: /\s--(standalone|server)\b/.test(` ${command}`) ? command : `${command} --standalone`;
|
|
54
70
|
return { command: standalone, file: ok ? 'opencode.json' : null, manual: ok ? null : `add mcp.eventmodelers = ${JSON.stringify(entry)} to opencode.json` };
|
|
55
71
|
},
|
|
56
72
|
gemini: (command, projectDir, url) => {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// The modeling agent's journal of the board prompts it takes: `prompt-received` when it claims one, then
|
|
2
|
+
// `prompt-finished` with the outcome. The platform decides who gets a prompt (an atomic claim); this is the
|
|
3
|
+
// agent's own record of what it had in hand.
|
|
4
|
+
|
|
5
|
+
import { appendEvent, readEvents } from './slice-events.js';
|
|
6
|
+
import { fold } from './slice-state.js';
|
|
7
|
+
import { redactString } from './redact.js';
|
|
8
|
+
|
|
9
|
+
// What identifies the prompt and what `replay` needs to submit it again (text, timeline, canvas context) — not the
|
|
10
|
+
// platform's own columns (status, claim, timestamps).
|
|
11
|
+
export function journalPromptReceived({ kitDir, prompt, meta }) {
|
|
12
|
+
if (!prompt?.id) return;
|
|
13
|
+
appendEvent(kitDir, {
|
|
14
|
+
type: 'prompt-received',
|
|
15
|
+
pid: process.pid,
|
|
16
|
+
prompt: {
|
|
17
|
+
id: prompt.id,
|
|
18
|
+
text: String(prompt.prompt ?? ''),
|
|
19
|
+
board_id: prompt.board_id,
|
|
20
|
+
timeline_id: prompt.timeline_id,
|
|
21
|
+
...(prompt.context ? { context: prompt.context } : {}),
|
|
22
|
+
priority: prompt.priority,
|
|
23
|
+
agent_id: prompt.agent_id ?? null,
|
|
24
|
+
node_id: prompt.node_id,
|
|
25
|
+
comment_id: prompt.comment_id,
|
|
26
|
+
},
|
|
27
|
+
}, meta);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// outcome: worked | released | already-settled | gone. A turn that failed is not finished: its entry stays
|
|
31
|
+
// open, and the next start recovers it.
|
|
32
|
+
export function journalPromptFinished({ kitDir, id, outcome, reason, meta }) {
|
|
33
|
+
appendEvent(kitDir, { type: 'prompt-finished', prompt: id, outcome, ...(reason ? { reason } : {}) }, meta);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── what a turn did on the board ────────────────────────────────────────────────
|
|
37
|
+
// `prompt-received` and `prompt-finished` say a prompt was taken and a turn ended; they cannot say what happened in
|
|
38
|
+
// between. Each board write the turn makes is journaled as it streams past — a comment, a slice moved to Blocked,
|
|
39
|
+
// nodes placed — so the log reads as what the agent did, and a turn that dies half way still shows how far it got.
|
|
40
|
+
// Reads are not journaled. Only what the runner can see is: a write made through curl in Bash is not.
|
|
41
|
+
|
|
42
|
+
const oneLine = (text, max) => {
|
|
43
|
+
const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
44
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
45
|
+
};
|
|
46
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
47
|
+
const count = (list) => (Array.isArray(list) ? list.length : 1);
|
|
48
|
+
|
|
49
|
+
// tool → what to journal; null for a tool that writes nothing to the board.
|
|
50
|
+
const BOARD_WRITES = {
|
|
51
|
+
add_comment: ({ comments = [] }) => {
|
|
52
|
+
const nodes = new Set(comments.map((c) => c?.nodeId).filter(Boolean)).size;
|
|
53
|
+
const first = oneLine(comments[0]?.text, 100);
|
|
54
|
+
return { action: 'commented', detail: `${plural(comments.length, 'comment')} on ${plural(nodes, 'node')}${first ? `: "${first}"` : ''}` };
|
|
55
|
+
},
|
|
56
|
+
update_comment: ({ action, commentId }) => ({ action: `comment-${action === 'delete' ? 'deleted' : 'resolved'}`, detail: String(commentId ?? '').slice(0, 8) }),
|
|
57
|
+
update_slice_status: ({ newStatus, sliceId, sliceTitle }) => ({ action: 'slice-status', detail: `${sliceTitle ? `"${oneLine(sliceTitle, 60)}"` : String(sliceId ?? '').slice(0, 8)} → ${newStatus}` }),
|
|
58
|
+
submit_node_events: ({ events = [] }) => {
|
|
59
|
+
const by = {};
|
|
60
|
+
for (const e of events) by[e?.type ?? 'event'] = (by[e?.type ?? 'event'] ?? 0) + 1;
|
|
61
|
+
return { action: 'nodes-changed', detail: Object.entries(by).map(([t, n]) => `${n} ${t}`).join(', ') };
|
|
62
|
+
},
|
|
63
|
+
place_element: ({ elements }) => ({ action: 'placed', detail: plural(count(elements), 'element') }),
|
|
64
|
+
delete_node: ({ nodeIds }) => ({ action: 'nodes-deleted', detail: plural(count(nodeIds), 'node') }),
|
|
65
|
+
create_slice: ({ type, status }) => ({ action: 'slice-created', detail: [type, status].filter(Boolean).join(', ') }),
|
|
66
|
+
create_chapter: ({ title }) => ({ action: 'chapter-created', detail: title ? `"${oneLine(title, 60)}"` : '' }),
|
|
67
|
+
add_column: ({ count: n = 1 }) => ({ action: 'columns-added', detail: String(n) }),
|
|
68
|
+
add_lane: ({ lanes }) => ({ action: 'lanes-added', detail: String(count(lanes)) }),
|
|
69
|
+
add_scenario: ({ scenarios }) => ({ action: 'scenarios-added', detail: String(count(scenarios)) }),
|
|
70
|
+
add_storyline: ({ storylines }) => ({ action: 'storylines-added', detail: String(count(storylines)) }),
|
|
71
|
+
set_connection: ({ connections }) => ({ action: 'connections-changed', detail: String(count(connections)) }),
|
|
72
|
+
link_element: () => ({ action: 'linked', detail: '' }),
|
|
73
|
+
create_screen: ({ screens }) => ({ action: 'screens-created', detail: String(count(screens)) }),
|
|
74
|
+
post_chat_message: ({ text }) => ({ action: 'chat-posted', detail: `"${oneLine(text, 100)}"` }),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// The journal entry for a tool call, or null when it is not a board write.
|
|
78
|
+
export function describeBoardAction(toolName, input) {
|
|
79
|
+
const describe = BOARD_WRITES[String(toolName).split('__').pop()];
|
|
80
|
+
return describe ? describe(input ?? {}) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Under `verbose` every call leaves exactly one entry: a board write as its action, any other call (a read, Bash, a
|
|
84
|
+
// skill) as a `tool-call` with `detail` — the caller's one-line account of its input — and both carry the call's full
|
|
85
|
+
// `input`, so `events --verbose` can show what was actually sent. The input is stored with the API token and anything
|
|
86
|
+
// JWT-shaped masked: a skill's arguments can hold credentials, and this log is a plain file.
|
|
87
|
+
//
|
|
88
|
+
// A call is journaled when it ends, as one entry: stamped with when it started, and carrying `durationMs` and, when the
|
|
89
|
+
// tool reported one, `error`. Timing costs nothing beyond a clock read at each end (createCallTracker).
|
|
90
|
+
export function journalPromptAction({ kitDir, id, toolName, input, verbose = false, detail, secrets = [], startedAt, durationMs, error = false, meta }) {
|
|
91
|
+
if (!id) return;
|
|
92
|
+
const done = describeBoardAction(toolName, input);
|
|
93
|
+
if (!done && !verbose) return;
|
|
94
|
+
const payload = verbose && input !== undefined ? { input: JSON.parse(redactString(JSON.stringify(input), secrets)) } : {};
|
|
95
|
+
const timing = { ...(startedAt ? { at: startedAt } : {}), ...(durationMs !== undefined ? { durationMs } : {}), ...(error ? { error: true } : {}) };
|
|
96
|
+
if (done) appendEvent(kitDir, { type: 'prompt-action', prompt: id, ...done, ...timing, ...payload }, meta);
|
|
97
|
+
else appendEvent(kitDir, { type: 'tool-call', prompt: id, tool: toolName, ...(detail ? { detail } : {}), ...timing, ...payload }, meta);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Which calls are in flight, and for how long. `begin` and `end` are a Map operation and a clock read; calls that are
|
|
101
|
+
// never going to be journaled should not be begun (shouldJournalCall). `drain` returns what never ended — a turn that
|
|
102
|
+
// died mid-call — without a duration.
|
|
103
|
+
export const shouldJournalCall = (toolName, input, verbose) => verbose || describeBoardAction(toolName, input) !== null;
|
|
104
|
+
|
|
105
|
+
export function createCallTracker({ now = () => performance.now(), clock = () => new Date().toISOString() } = {}) {
|
|
106
|
+
const open = new Map();
|
|
107
|
+
return {
|
|
108
|
+
begin(id, call) { open.set(id, { ...call, startedAt: clock(), t0: now() }); },
|
|
109
|
+
end(id, { isError = false } = {}) {
|
|
110
|
+
const call = open.get(id);
|
|
111
|
+
if (!call) return null;
|
|
112
|
+
open.delete(id);
|
|
113
|
+
const { t0, ...rest } = call;
|
|
114
|
+
return { ...rest, durationMs: Math.round(now() - t0), error: !!isError };
|
|
115
|
+
},
|
|
116
|
+
drain() {
|
|
117
|
+
const rest = [...open.values()].map(({ t0, ...call }) => call);
|
|
118
|
+
open.clear();
|
|
119
|
+
return rest;
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function openPrompts(kitDir) {
|
|
125
|
+
return fold(readEvents(kitDir)).prompts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── restart recovery ────────────────────────────────────────────────────────────
|
|
129
|
+
// The platform records who holds a prompt (`claimed_by`, the agent id) and never re-queues one by itself. A
|
|
130
|
+
// freshly started agent holds nothing, so whatever the platform still shows as CLAIMED / IN_PROGRESS under its
|
|
131
|
+
// own id is what an earlier run left behind — handed back at start. (Prompts of an agent that never comes back
|
|
132
|
+
// are the platform's to release, once its heartbeat has lapsed.) Two live processes must not share one agent
|
|
133
|
+
// id: the second would hand back the first one's prompts.
|
|
134
|
+
|
|
135
|
+
const REASON = 'recovered after a restart';
|
|
136
|
+
|
|
137
|
+
// `fetchHeld(agentId)` → [{ id, status }] the platform shows as held by that agent; `release(id)` puts a prompt
|
|
138
|
+
// back on the queue and throws an error carrying `status` when the platform refuses. The journal's open entries
|
|
139
|
+
// are closed along the way: released ones as 'released', the rest as 'already-settled'. Whatever cannot be
|
|
140
|
+
// settled now (platform unreachable, a 5xx on a hand-back) stays open and is tried again at the next start.
|
|
141
|
+
export async function recoverOwnPrompts({ kitDir, meta, agentId, fetchHeld, release }) {
|
|
142
|
+
const open = openPrompts(kitDir);
|
|
143
|
+
const result = { released: 0, settled: 0, left: 0 };
|
|
144
|
+
if (!agentId) return { ...result, left: open.length };
|
|
145
|
+
|
|
146
|
+
let held;
|
|
147
|
+
try {
|
|
148
|
+
held = await fetchHeld(agentId);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(`[modeling] could not ask the platform which prompts this agent was holding${open.length ? ` — leaving ${open.length} unfinished for the next start` : ''}:`, err.message);
|
|
151
|
+
return { ...result, left: open.length };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const finish = (id, outcome) => journalPromptFinished({ kitDir, id, outcome, reason: REASON, meta });
|
|
155
|
+
const isOpen = (id) => open.some((p) => p.id === id);
|
|
156
|
+
for (const { id } of held) {
|
|
157
|
+
try {
|
|
158
|
+
await release(id);
|
|
159
|
+
if (isOpen(id)) finish(id, 'released');
|
|
160
|
+
result.released++;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
// 409: it is already back on the queue; 404: it was deleted — nothing left to hand back.
|
|
163
|
+
if (err?.status === 409 || err?.status === 404) {
|
|
164
|
+
if (isOpen(id)) finish(id, err.status === 404 ? 'gone' : 'already-settled');
|
|
165
|
+
result.settled++;
|
|
166
|
+
} else {
|
|
167
|
+
console.error(`[modeling] could not hand prompt ${id} back — it stays held for the next start:`, err.message);
|
|
168
|
+
result.left++;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Entries the platform does not show as held any more finished (or went) after all.
|
|
173
|
+
const heldIds = new Set(held.map((h) => h.id));
|
|
174
|
+
for (const p of open) {
|
|
175
|
+
if (heldIds.has(p.id)) continue;
|
|
176
|
+
finish(p.id, 'already-settled');
|
|
177
|
+
result.settled++;
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// What asks the platform which prompts an agent holds: GET /prompts?claimed_by=, which reads and claims nothing.
|
|
183
|
+
// Like the other prompt routes it needs the API token *and* an authenticated user — the realtime JWT the agent
|
|
184
|
+
// already holds for /prompts/next.
|
|
185
|
+
export function heldPromptsRequest({ baseUrl, organizationId, boardId, token, jwt, agentId }) {
|
|
186
|
+
const query = new URLSearchParams({ board_id: boardId, claimed_by: agentId, status: 'CLAIMED,IN_PROGRESS', limit: '500' });
|
|
187
|
+
return {
|
|
188
|
+
url: `${baseUrl}/api/org/${organizationId}/prompts?${query}`,
|
|
189
|
+
headers: { 'x-token': token, Authorization: `Bearer ${jwt}`, 'x-agent-id': agentId },
|
|
190
|
+
};
|
|
191
|
+
}
|