trodo-cli 0.0.0-stage → 0.3.0
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 +33 -2
- package/bin/trodo.js +7 -0
- package/lib/args.js +45 -0
- package/lib/commands/agents.js +63 -0
- package/lib/commands/api.js +37 -0
- package/lib/commands/auth.js +165 -0
- package/lib/commands/conversations.js +49 -0
- package/lib/commands/datasets.js +182 -0
- package/lib/commands/evals.js +138 -0
- package/lib/commands/issues.js +40 -0
- package/lib/commands/prompts.js +125 -0
- package/lib/commands/runs.js +118 -0
- package/lib/commands/setup.js +34 -0
- package/lib/commands/spans.js +38 -0
- package/lib/commands/traces.js +112 -0
- package/lib/commands/users.js +14 -0
- package/lib/common.js +69 -0
- package/lib/config.js +78 -0
- package/lib/filters.js +157 -0
- package/lib/http.js +74 -0
- package/lib/main.js +137 -0
- package/lib/output.js +61 -0
- package/package.json +29 -4
- package/skills/trodo/SKILL.md +121 -0
- package/skills/trodo-datasets/SKILL.md +116 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
const { usage, pick, int, unitVerdicts } = require('../common');
|
|
7
|
+
|
|
8
|
+
/** trodo evals list: the names --eval takes, and each eval's steps (STEP.FIELD). */
|
|
9
|
+
async function listEvals(_args, flags) {
|
|
10
|
+
const { evals } = await client(config.resolve(flags)).get('/evals');
|
|
11
|
+
const rows = (evals || []).map((e) => ({
|
|
12
|
+
...e,
|
|
13
|
+
step_fields: (e.steps || []).flatMap((s) => (s.fields || []).map((f) => `${s.name}.${f.name} (${f.type === 'enum' ? (f.values || []).join('|') : f.type})`)).join('; '),
|
|
14
|
+
}));
|
|
15
|
+
print(flags.table ? rows : evals, flags, [['name', 'Eval', 44], ['target', 'Target', 12], ['enabled', 'On', 5], ['step_fields', 'Steps (STEP.FIELD)', 70]]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function resolveEval(api, ref) {
|
|
19
|
+
const { evals } = await api.get('/evals');
|
|
20
|
+
return pick(evals || [], ref, { kind: 'eval', listCmd: 'trodo evals list' });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** trodo evals get EVAL: the eval, its production version's graph and its 30-day stats. */
|
|
24
|
+
async function getEval([ref], flags) {
|
|
25
|
+
const api = client(config.resolve(flags));
|
|
26
|
+
const e = await resolveEval(api, ref);
|
|
27
|
+
const [lineage, stats] = await Promise.all([
|
|
28
|
+
api.get(`/eval3/definitions/${e.id}`),
|
|
29
|
+
api.get(`/eval3/definitions/${e.id}/stats`).catch(() => null),
|
|
30
|
+
]);
|
|
31
|
+
print({ ...lineage, steps: e.steps, stats: stats || undefined }, flags);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** "7d" / "48h" / "30" → whole days for the verdict list (1-365). */
|
|
35
|
+
function daysOf(since) {
|
|
36
|
+
if (since == null || since === true) return undefined;
|
|
37
|
+
const m = /^(\d+)\s*([hd]?)$/.exec(String(since).trim());
|
|
38
|
+
if (!m) throw usage('--since takes days or hours here: 7d, 48h, 30.');
|
|
39
|
+
const n = Number(m[1]);
|
|
40
|
+
return Math.min(365, Math.max(1, m[2] === 'h' ? Math.ceil(n / 24) : n));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* trodo evals verdicts EVAL [--failed | --passed] [--since 7d] [--version N] [--limit 50] [--offset N]
|
|
45
|
+
* trodo evals verdicts --run RUN_ID | --span SPAN_ID | --conversation ID
|
|
46
|
+
* The eval's verdicts, newest first; or every eval's newest verdict on one unit.
|
|
47
|
+
*/
|
|
48
|
+
async function verdicts([ref], flags) {
|
|
49
|
+
const api = client(config.resolve(flags));
|
|
50
|
+
const unit = ['run', 'span', 'conversation'].find((k) => flags[k] && flags[k] !== true);
|
|
51
|
+
const cols = [['id', 'Verdict', 36], ['lineage_name', 'Eval', 34], ['passed', 'Passed', 6], ['score', 'Score', 6], ['reason_code', 'Reason', 22], ['created_at', 'At', 24]];
|
|
52
|
+
if (unit) {
|
|
53
|
+
const out = await api.get(`/eval3/units/${unit}/${encodeURIComponent(String(flags[unit]))}/verdicts`);
|
|
54
|
+
print(unitVerdicts(out), flags, cols);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (flags.failed && flags.passed) throw usage('Give --failed or --passed, not both.');
|
|
58
|
+
const e = await resolveEval(api, ref);
|
|
59
|
+
const q = {
|
|
60
|
+
limit: int(flags.limit, 50, 200),
|
|
61
|
+
offset: flags.offset,
|
|
62
|
+
days: daysOf(flags.since),
|
|
63
|
+
version: flags.version,
|
|
64
|
+
...(flags.failed ? { passed: 'false' } : flags.passed ? { passed: 'true' } : {}),
|
|
65
|
+
};
|
|
66
|
+
const out = await api.get(`/eval3/definitions/${e.id}/runs`, q);
|
|
67
|
+
const rows = (out.runs || out.verdicts || []).map((r) => ({ lineage_name: e.name, ...r }));
|
|
68
|
+
print(flags.table ? rows : { eval: e.name, ...out }, flags, cols);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** trodo evals verdict VERDICT_ID: one verdict, every step it took and why. */
|
|
72
|
+
async function verdict([id], flags) {
|
|
73
|
+
if (!id) throw usage('Usage: trodo evals verdict VERDICT_ID');
|
|
74
|
+
print(await client(config.resolve(flags)).get(`/eval3/runs/${encodeURIComponent(id)}`), flags);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* trodo evals mark VERDICT_ID --right
|
|
79
|
+
* trodo evals mark VERDICT_ID --wrong --should pass|fail --reason TEXT
|
|
80
|
+
* A person's thumbs on a verdict. A thumbs-down says what it should have been and
|
|
81
|
+
* why; it becomes that verdict row's Expected and feeds the eval's improvement.
|
|
82
|
+
*/
|
|
83
|
+
async function mark([id], flags) {
|
|
84
|
+
if (!id) throw usage('Usage: trodo evals mark VERDICT_ID --right | --wrong --should pass|fail --reason TEXT');
|
|
85
|
+
if (Boolean(flags.right) === Boolean(flags.wrong)) throw usage('Give --right or --wrong.');
|
|
86
|
+
const body = { source: 'v3', eval_run_id: String(id), thumbs: flags.right ? 'up' : 'down' };
|
|
87
|
+
if (flags.wrong) {
|
|
88
|
+
const should = String(flags.should || '');
|
|
89
|
+
if (!['pass', 'fail'].includes(should)) throw usage('--wrong needs --should pass or --should fail.');
|
|
90
|
+
if (!flags.reason || flags.reason === true) throw usage('--wrong needs --reason "why".');
|
|
91
|
+
body.corrected_passed = should === 'pass';
|
|
92
|
+
body.reason_text = String(flags.reason);
|
|
93
|
+
} else if (flags.reason && flags.reason !== true) body.reason_text = String(flags.reason);
|
|
94
|
+
print(await client(config.resolve(flags)).post('/eval3/feedback', body), flags);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* trodo evals queue [--eval EVAL] [--graded] [--limit 50]
|
|
99
|
+
* Verdicts waiting on a person: an eval's human step stops there until someone
|
|
100
|
+
* picks one of its choices.
|
|
101
|
+
*/
|
|
102
|
+
async function queue(_a, flags) {
|
|
103
|
+
const api = client(config.resolve(flags));
|
|
104
|
+
const lineageId = flags.eval && flags.eval !== true ? (await resolveEval(api, flags.eval)).id : undefined;
|
|
105
|
+
const out = await api.get('/eval3/queue', { status: flags.graded ? 'graded' : 'open', lineage_id: lineageId, limit: int(flags.limit, 50, 200), offset: flags.offset });
|
|
106
|
+
print(out.items || out, flags, [['id', 'Item', 36], ['lineage_name', 'Eval', 34], ['node_id', 'Step', 22], ['status', 'Status', 8], ['created_at', 'Waiting since', 24]]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** trodo evals review ITEM_ID: one waiting item with the path the eval took to it. */
|
|
110
|
+
async function review([id], flags) {
|
|
111
|
+
if (!id) throw usage('Usage: trodo evals review ITEM_ID');
|
|
112
|
+
print(await client(config.resolve(flags)).get(`/eval3/queue/${encodeURIComponent(id)}`), flags);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** trodo evals grade ITEM_ID CHOICE: answer the human step; the eval carries on from there. */
|
|
116
|
+
async function grade([id, choice], flags) {
|
|
117
|
+
if (!id || !choice) throw usage('Usage: trodo evals grade ITEM_ID CHOICE (`trodo evals review ITEM_ID` lists the choices)');
|
|
118
|
+
print(await client(config.resolve(flags)).post(`/eval3/queue/${encodeURIComponent(id)}/grade`, { choice: String(choice) }), flags);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* trodo evals steps EVAL [--since 30d] [--version N]
|
|
123
|
+
* The paths verdicts took through the eval's steps (its nodes), each with how
|
|
124
|
+
* many runs took it and how they came out: which step decides most failures.
|
|
125
|
+
*/
|
|
126
|
+
async function steps([ref], flags) {
|
|
127
|
+
const api = client(config.resolve(flags));
|
|
128
|
+
const e = await resolveEval(api, ref);
|
|
129
|
+
const out = await api.get(`/eval3/definitions/${e.id}/steps`, { days: daysOf(flags.since) || 30, version: flags.version });
|
|
130
|
+
if (flags.table) {
|
|
131
|
+
const rows = (out.paths || []).map((p) => ({ path: (p.names || p.path || []).join(' → '), runs: p.runs, share: p.share, passed: p.passed, failed: p.failed, errored: p.errored }));
|
|
132
|
+
print(rows, flags, [['path', 'Path through the steps', 90], ['runs', 'Runs', 6], ['share', 'Share', 6], ['passed', 'Passed', 6], ['failed', 'Failed', 6], ['errored', 'Errored', 7]]);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
print({ eval: e.name, steps: e.steps, ...out }, flags);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = { list: listEvals, get: getEval, verdicts, verdict, mark, queue, review, grade, steps, daysOf };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
const { usage, int } = require('../common');
|
|
7
|
+
|
|
8
|
+
const csv = (v) => (v === undefined || v === true ? undefined : [].concat(v).join(','));
|
|
9
|
+
|
|
10
|
+
/** trodo issues list [--status open,regression,closed] [--agent NAME] [--search TEXT] [--limit 50] */
|
|
11
|
+
async function list(_a, flags) {
|
|
12
|
+
const out = await client(config.resolve(flags)).get('/issues', {
|
|
13
|
+
status: csv(flags.status), agent: csv(flags.agent), search: flags.search !== true ? flags.search : undefined,
|
|
14
|
+
limit: int(flags.limit, 50, 200), offset: flags.offset,
|
|
15
|
+
});
|
|
16
|
+
print(out.issues || out, flags, [['id', 'Id', 6], ['status', 'Status', 10], ['agent_name', 'Agent', 28], ['title', 'Issue', 60], ['evidence_count', 'Evidence', 8], ['last_seen_at', 'Last seen', 24]]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** trodo issues get ID: the issue, its evidence (runs, spans, conversations) and its timeline. */
|
|
20
|
+
async function get([id], flags) {
|
|
21
|
+
if (!id) throw usage('Usage: trodo issues get ISSUE_ID');
|
|
22
|
+
print(await client(config.resolve(flags)).get(`/issues/${encodeURIComponent(id)}`), flags);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** trodo issues failures ID: the recorded inputs of every failure, to replay against a fix. */
|
|
26
|
+
async function failures([id], flags) {
|
|
27
|
+
if (!id) throw usage('Usage: trodo issues failures ISSUE_ID');
|
|
28
|
+
print(await client(config.resolve(flags)).get(`/issues/${encodeURIComponent(id)}/failures`, { limit: flags.limit, cursor: flags.cursor !== true ? flags.cursor : undefined }), flags);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function setStatus(id, status, flags) {
|
|
32
|
+
if (!id) throw usage(`Usage: trodo issues ${status === 'closed' ? 'close' : 'reopen'} ISSUE_ID`);
|
|
33
|
+
print(await client(config.resolve(flags)).post(`/issues/${encodeURIComponent(id)}/status`, { status }), flags);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** trodo issues close ID: closed; a new failure of the same kind reopens it as a regression. */
|
|
37
|
+
const close = ([id], flags) => setStatus(id, 'closed', flags);
|
|
38
|
+
const reopen = ([id], flags) => setStatus(id, 'open', flags);
|
|
39
|
+
|
|
40
|
+
module.exports = { list, get, failures, close, reopen };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const { client } = require('../http');
|
|
6
|
+
const { print } = require('../output');
|
|
7
|
+
const { usage, pick } = require('../common');
|
|
8
|
+
|
|
9
|
+
/** What a prompt version is, without who saved it: what `pull` writes and `push` reads. */
|
|
10
|
+
const CONTENT_KEYS = ['messages', 'model', 'tools', 'response_format', 'variables'];
|
|
11
|
+
|
|
12
|
+
/** A refusal to edit, said as what to do about it. */
|
|
13
|
+
async function editing(prompt, fn) {
|
|
14
|
+
try { return await fn(); } catch (e) {
|
|
15
|
+
if (e && e.status === 403 && e.body && e.body.error === 'forbidden_edit') {
|
|
16
|
+
throw Object.assign(new Error(`You can't change "${prompt}": only its owner and the editors they add can. Ask the owner to add you (prompt page → Editors).`), { exitCode: 1 });
|
|
17
|
+
}
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function resolvePrompt(api, ref) {
|
|
23
|
+
const { prompts } = await api.get('/prompts');
|
|
24
|
+
const hit = pick(prompts || [], ref, { kind: 'prompt', listCmd: 'trodo prompts list' });
|
|
25
|
+
const { prompt } = await api.get(`/prompts/${hit.id}`);
|
|
26
|
+
return prompt;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The version a --version N or --label L names; the newest one otherwise. */
|
|
30
|
+
function versionOf(prompt, flags) {
|
|
31
|
+
const versions = prompt.versions || [];
|
|
32
|
+
if (flags.version !== undefined && flags.version !== true) {
|
|
33
|
+
const v = versions.find((x) => String(x.version_no) === String(flags.version));
|
|
34
|
+
if (!v) throw usage(`"${prompt.name}" has no version ${flags.version}. \`trodo prompts versions "${prompt.name}"\` lists them.`);
|
|
35
|
+
return v;
|
|
36
|
+
}
|
|
37
|
+
if (flags.label !== undefined && flags.label !== true) {
|
|
38
|
+
const v = versions.find((x) => (x.labels || []).includes(String(flags.label)));
|
|
39
|
+
if (!v) throw usage(`No version of "${prompt.name}" has the label "${flags.label}".`);
|
|
40
|
+
return v;
|
|
41
|
+
}
|
|
42
|
+
return versions.reduce((a, b) => (!a || Number(b.version_no) > Number(a.version_no) ? b : a), null);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function list(_a, flags) {
|
|
46
|
+
const { prompts } = await client(config.resolve(flags)).get('/prompts', { label: flags.label !== true ? flags.label : undefined, tag: flags.tag !== true ? flags.tag : undefined });
|
|
47
|
+
print(prompts || [], flags, [['name', 'Prompt', 36], ['current_version', 'Version', 8], ['labels', 'Labels', 24], ['id', 'Id', 36], ['updated_at', 'Updated', 24]]);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** trodo prompts get PROMPT [--version N | --label production]: one version, whole. */
|
|
51
|
+
async function get([ref], flags) {
|
|
52
|
+
const prompt = await resolvePrompt(client(config.resolve(flags)), ref);
|
|
53
|
+
const v = versionOf(prompt, flags);
|
|
54
|
+
print({ id: prompt.id, name: prompt.name, description: prompt.description, tags: prompt.tags, version: v }, flags);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function versions([ref], flags) {
|
|
58
|
+
const prompt = await resolvePrompt(client(config.resolve(flags)), ref);
|
|
59
|
+
const rows = (prompt.versions || []).map((v) => ({ version_no: v.version_no, labels: v.labels, commit_message: v.commit_message, created_by: v.created_by_email, created_at: v.created_at, content_hash: v.content_hash }));
|
|
60
|
+
print(rows, flags, [['version_no', 'v', 4], ['labels', 'Labels', 22], ['commit_message', 'Message', 44], ['created_by', 'By', 24], ['created_at', 'At', 24]]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** trodo prompts pull PROMPT [--version N | --label L] [-o prompt.json]: the content to edit and push back. */
|
|
64
|
+
async function pull([ref], flags) {
|
|
65
|
+
const prompt = await resolvePrompt(client(config.resolve(flags)), ref);
|
|
66
|
+
const v = versionOf(prompt, flags);
|
|
67
|
+
const out = { name: prompt.name, version_no: v.version_no };
|
|
68
|
+
for (const k of CONTENT_KEYS) out[k] = v[k];
|
|
69
|
+
if (flags.out && flags.out !== true) {
|
|
70
|
+
fs.writeFileSync(String(flags.out), `${JSON.stringify(out, null, 2)}\n`);
|
|
71
|
+
process.stderr.write(`${prompt.name} v${v.version_no} → ${flags.out}\n`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
print(out, flags);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* trodo prompts push PROMPT -f prompt.json [--message "why"] [--label staging]
|
|
79
|
+
* A new version from a file `pull` wrote (or any { messages, model?, tools?,
|
|
80
|
+
* variables? }). The prompt is made when no prompt has that name. Content equal
|
|
81
|
+
* to the newest version cuts no version.
|
|
82
|
+
*/
|
|
83
|
+
async function push([ref], flags) {
|
|
84
|
+
const file = flags.file;
|
|
85
|
+
if (!file || file === true) throw usage('Usage: trodo prompts push PROMPT -f prompt.json [--message "why"] [--label staging]');
|
|
86
|
+
let content;
|
|
87
|
+
try { content = JSON.parse(fs.readFileSync(String(file), 'utf8')); } catch (e) { throw usage(`${file} is not a JSON prompt: ${e.message}`); }
|
|
88
|
+
if (!Array.isArray(content.messages) || !content.messages.length) throw usage(`${file} has no messages.`);
|
|
89
|
+
const api = client(config.resolve(flags));
|
|
90
|
+
const name = ref && ref !== true ? String(ref) : content.name;
|
|
91
|
+
if (!name) throw usage('Name the prompt.');
|
|
92
|
+
const { prompts } = await api.get('/prompts');
|
|
93
|
+
let target = (prompts || []).find((p) => p.id === name || p.name === name);
|
|
94
|
+
let created = false;
|
|
95
|
+
if (!target) {
|
|
96
|
+
const made = await api.post('/prompts', { name, ...(content.description ? { description: content.description } : {}) });
|
|
97
|
+
target = made.prompt || made;
|
|
98
|
+
created = true;
|
|
99
|
+
}
|
|
100
|
+
const body = { messages: content.messages };
|
|
101
|
+
for (const k of ['model', 'tools', 'response_format', 'variables']) if (content[k] !== undefined) body[k] = content[k];
|
|
102
|
+
if (flags.message && flags.message !== true) body.commit_message = String(flags.message);
|
|
103
|
+
if (flags.label && flags.label !== true) body.labels = [].concat(flags.label).map(String);
|
|
104
|
+
const out = await editing(name, () => api.post(`/prompts/${target.id}/versions`, body));
|
|
105
|
+
const v = out.version || {};
|
|
106
|
+
print({ prompt: name, id: target.id, created, version_no: v.version_no, labels: v.labels, unchanged: Boolean(out.unchanged) }, flags);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* trodo prompts label PROMPT VERSION LABEL [--remove]
|
|
111
|
+
* Moves LABEL (say production) to VERSION. Agents that fetch the prompt by that
|
|
112
|
+
* label run the new version from their next call.
|
|
113
|
+
*/
|
|
114
|
+
async function label([ref, versionNo, name], flags) {
|
|
115
|
+
if (!ref || !versionNo || !name) throw usage('Usage: trodo prompts label PROMPT VERSION LABEL [--remove]');
|
|
116
|
+
const api = client(config.resolve(flags));
|
|
117
|
+
const prompt = await resolvePrompt(api, ref);
|
|
118
|
+
const v = versionOf(prompt, { version: versionNo });
|
|
119
|
+
const out = await editing(prompt.name, () => (flags.remove
|
|
120
|
+
? api.del(`/prompts/${prompt.id}/versions/${v.version_no}/label/${encodeURIComponent(String(name))}`)
|
|
121
|
+
: api.post(`/prompts/${prompt.id}/versions/${v.version_no}/label`, { label: String(name) })));
|
|
122
|
+
print({ prompt: prompt.name, version_no: v.version_no, ...(out.version ? { labels: out.version.labels } : out) }, flags);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = { list, get, versions, pull, push, label };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
const { buildSpec } = require('./traces');
|
|
7
|
+
const { usage, listQuery, pages, unitVerdicts, int } = require('../common');
|
|
8
|
+
|
|
9
|
+
const DAY = 86400e3;
|
|
10
|
+
/** A filter's time → { time_start, time_end } for the lists. */
|
|
11
|
+
function windowOf(time, now = Date.now()) {
|
|
12
|
+
if (time.from || time.to) return { time_start: time.from || new Date(now - 7 * DAY).toISOString(), time_end: time.to || new Date(now).toISOString() };
|
|
13
|
+
const p = String(time.preset || '7d');
|
|
14
|
+
const start = new Date(now); start.setHours(0, 0, 0, 0);
|
|
15
|
+
if (p === 'today') return { time_start: start.toISOString(), time_end: new Date(now).toISOString() };
|
|
16
|
+
if (p === 'yesterday') return { time_start: new Date(start.getTime() - DAY).toISOString(), time_end: new Date(start.getTime() - 1).toISOString() };
|
|
17
|
+
const m = /^(\d+)([mhd])$/.exec(p);
|
|
18
|
+
const n = m ? Number(m[1]) : 7;
|
|
19
|
+
const unit = !m ? DAY : m[2] === 'h' ? 3600e3 : m[2] === 'd' ? DAY : (['3m', '6m', '12m'].includes(p) ? 30 * DAY : 60e3);
|
|
20
|
+
return { time_start: new Date(now - n * unit).toISOString(), time_end: new Date(now).toISOString() };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** trodo runs list [filter flags] [--limit 50] [--offset N] */
|
|
24
|
+
async function listRuns(_args, flags) {
|
|
25
|
+
const api = client(config.resolve(flags));
|
|
26
|
+
const spec = await buildSpec(api, flags, 'runs');
|
|
27
|
+
const q = listQuery(spec, windowOf);
|
|
28
|
+
const rows = await pages(flags, async (limit, offset) => { const out = await api.get('/runs', { ...q, limit, offset }); return out.runs || []; }, { max: 100 });
|
|
29
|
+
print(rows, flags, [['run_id', 'Run', 36], ['agent_name', 'Agent', 30], ['status', 'Status', 8], ['started_at', 'Started', 24], ['duration_ms', 'ms', 8]]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The spans of a run as an indented tree, one line each: what a person reads first. */
|
|
33
|
+
function spanTree(spans) {
|
|
34
|
+
const byId = new Map(spans.map((s) => [s.span_id, s]));
|
|
35
|
+
const kids = new Map();
|
|
36
|
+
const roots = [];
|
|
37
|
+
const order = (a, b) => String(a.started_at || '').localeCompare(String(b.started_at || ''));
|
|
38
|
+
for (const s of [...spans].sort(order)) {
|
|
39
|
+
if (s.parent_span_id && byId.has(s.parent_span_id)) {
|
|
40
|
+
if (!kids.has(s.parent_span_id)) kids.set(s.parent_span_id, []);
|
|
41
|
+
kids.get(s.parent_span_id).push(s);
|
|
42
|
+
} else roots.push(s);
|
|
43
|
+
}
|
|
44
|
+
const lines = [];
|
|
45
|
+
const label = (s) => {
|
|
46
|
+
const bits = [`${s.name || s.tool_name || s.kind}`, `[${s.kind || 'span'}]`];
|
|
47
|
+
if (s.model) bits.push(s.model);
|
|
48
|
+
if (s.duration_ms != null) bits.push(`${(Number(s.duration_ms) / 1000).toFixed(2)}s`);
|
|
49
|
+
const tok = (Number(s.input_tokens) || 0) + (Number(s.output_tokens) || 0);
|
|
50
|
+
if (tok) bits.push(`${tok} tok`);
|
|
51
|
+
if (s.status && s.status !== 'ok') bits.push(`✗ ${s.status}${s.error_message ? `: ${String(s.error_message).slice(0, 80)}` : ''}`);
|
|
52
|
+
bits.push(s.span_id);
|
|
53
|
+
return bits.join(' ');
|
|
54
|
+
};
|
|
55
|
+
const walk = (s, prefix, last, depth) => {
|
|
56
|
+
lines.push(`${depth ? `${prefix}${last ? '└─ ' : '├─ '}` : ''}${label(s)}`);
|
|
57
|
+
const c = kids.get(s.span_id) || [];
|
|
58
|
+
c.forEach((k, i) => walk(k, depth ? `${prefix}${last ? ' ' : '│ '}` : '', i === c.length - 1, depth + 1));
|
|
59
|
+
};
|
|
60
|
+
roots.forEach((r, i) => walk(r, '', i === roots.length - 1, 0));
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* trodo runs get RUN_ID [--tree]: the run, its spans, messages, feedback and
|
|
66
|
+
* evaluations. --tree prints the span tree instead of JSON.
|
|
67
|
+
*/
|
|
68
|
+
async function getRun([id], flags) {
|
|
69
|
+
if (!id) throw usage('Usage: trodo runs get RUN_ID [--tree]');
|
|
70
|
+
const api = client(config.resolve(flags));
|
|
71
|
+
const [detail, verdicts] = await Promise.all([
|
|
72
|
+
api.get(`/runs/${encodeURIComponent(id)}`, { include: 'spans,messages' }),
|
|
73
|
+
api.get(`/eval3/units/run/${encodeURIComponent(id)}/verdicts`).catch(() => null),
|
|
74
|
+
]);
|
|
75
|
+
if (flags.tree) {
|
|
76
|
+
const r = detail.run || {};
|
|
77
|
+
const head = `${r.agent_name || 'run'} ${r.status || ''} ${r.started_at || ''} ${r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(2)}s` : ''} ${r.run_id || id}`;
|
|
78
|
+
process.stdout.write(`${head}\n${spanTree(detail.spans || [])}\n`);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
print({ ...detail, evaluations: unitVerdicts(verdicts) }, flags);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** trodo runs tree RUN_ID: the span tree alone. */
|
|
85
|
+
const tree = (args, flags) => getRun(args, { ...flags, tree: true });
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* trodo runs feedback RUN_ID [--thumbs up|down] [--rating 1-5] [--comment TEXT] [--user DISTINCT_ID]
|
|
89
|
+
* A person's feedback on the run, as the SDK and the run page record it.
|
|
90
|
+
*/
|
|
91
|
+
async function feedback([id], flags) {
|
|
92
|
+
if (!id) throw usage('Usage: trodo runs feedback RUN_ID --thumbs up|down [--rating 1-5] [--comment TEXT]');
|
|
93
|
+
const body = {};
|
|
94
|
+
if (flags.thumbs !== undefined) {
|
|
95
|
+
const t = String(flags.thumbs);
|
|
96
|
+
if (!['up', 'down'].includes(t)) throw usage('--thumbs is up or down.');
|
|
97
|
+
body.satisfaction = t === 'up' ? 'positive' : 'negative';
|
|
98
|
+
}
|
|
99
|
+
if (flags.rating !== undefined) {
|
|
100
|
+
const n = Number(flags.rating);
|
|
101
|
+
if (!Number.isInteger(n) || n < 1 || n > 5) throw usage('--rating is a whole number from 1 to 5.');
|
|
102
|
+
body.rating = n;
|
|
103
|
+
}
|
|
104
|
+
if (flags.comment !== undefined && flags.comment !== true) body.comment = String(flags.comment);
|
|
105
|
+
if (flags.user !== undefined && flags.user !== true) body.distinct_id = String(flags.user);
|
|
106
|
+
if (!Object.keys(body).some((k) => ['satisfaction', 'rating', 'comment'].includes(k))) throw usage('Give --thumbs, --rating or --comment.');
|
|
107
|
+
print(await client(config.resolve(flags)).post(`/runs/${encodeURIComponent(id)}/feedback`, body), flags);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** trodo runs search TEXT [--limit 50]: runs whose input, output or spans mention TEXT. */
|
|
111
|
+
async function search(words, flags) {
|
|
112
|
+
const q = words.join(' ').trim();
|
|
113
|
+
if (!q) throw usage('Usage: trodo runs search TEXT [--limit 50]');
|
|
114
|
+
const out = await client(config.resolve(flags)).get('/runs/search', { q, limit: int(flags.limit, 50, 200) });
|
|
115
|
+
print(out.runs || out, flags, [['run_id', 'Run', 36], ['agent_name', 'Agent', 30], ['status', 'Status', 8], ['started_at', 'Started', 24], ['distinct_id', 'User', 26]]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { list: listRuns, get: getRun, tree, feedback, search, windowOf, spanTree };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { print } = require('../output');
|
|
7
|
+
const { usage } = require('../common');
|
|
8
|
+
|
|
9
|
+
const SKILLS_DIR = path.join(__dirname, '..', '..', 'skills');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* trodo setup claude [--global] | trodo setup --dir DIR
|
|
13
|
+
* Installs the skills this CLI ships (trodo: read and act on traces; trodo-datasets:
|
|
14
|
+
* training data) where a coding agent finds them: ./.claude/skills (this project),
|
|
15
|
+
* ~/.claude/skills with --global, or any folder with --dir.
|
|
16
|
+
*/
|
|
17
|
+
async function setup([agent], flags) {
|
|
18
|
+
let root;
|
|
19
|
+
if (flags.dir && flags.dir !== true) root = path.resolve(String(flags.dir));
|
|
20
|
+
else if (agent === 'claude') root = flags.global ? path.join(os.homedir(), '.claude', 'skills') : path.resolve('.claude', 'skills');
|
|
21
|
+
else throw usage('Usage: trodo setup claude [--global] or trodo setup --dir SKILLS_DIR');
|
|
22
|
+
const installed = [];
|
|
23
|
+
for (const name of fs.readdirSync(SKILLS_DIR)) {
|
|
24
|
+
const src = path.join(SKILLS_DIR, name, 'SKILL.md');
|
|
25
|
+
if (!fs.existsSync(src)) continue;
|
|
26
|
+
const dest = path.join(root, name);
|
|
27
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
28
|
+
fs.copyFileSync(src, path.join(dest, 'SKILL.md'));
|
|
29
|
+
installed.push(path.join(dest, 'SKILL.md'));
|
|
30
|
+
}
|
|
31
|
+
print({ installed }, flags);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { setup };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
const { buildSpec } = require('./traces');
|
|
7
|
+
const { windowOf } = require('./runs');
|
|
8
|
+
const { usage, listQuery, pages, unitVerdicts, int } = require('../common');
|
|
9
|
+
|
|
10
|
+
/** trodo spans list [filter flags] [--limit 50] [--offset N]: newest first. */
|
|
11
|
+
async function list(_args, flags) {
|
|
12
|
+
const api = client(config.resolve(flags));
|
|
13
|
+
const spec = await buildSpec(api, flags, 'spans');
|
|
14
|
+
const q = listQuery(spec, windowOf);
|
|
15
|
+
const rows = await pages(flags, async (limit, offset) => { const out = await api.get('/spans', { ...q, limit, offset }); return out.spans || []; });
|
|
16
|
+
print(rows, flags, [['span_id', 'Span', 36], ['name', 'Name', 28], ['kind', 'Kind', 8], ['model', 'Model', 22], ['status', 'Status', 8], ['duration_ms', 'ms', 8], ['run_id', 'Run', 36]]);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** trodo spans get SPAN_ID: the span with its full input and output, and its evaluations. */
|
|
20
|
+
async function get([id], flags) {
|
|
21
|
+
if (!id) throw usage('Usage: trodo spans get SPAN_ID');
|
|
22
|
+
const api = client(config.resolve(flags));
|
|
23
|
+
const [detail, verdicts] = await Promise.all([
|
|
24
|
+
api.get(`/spans/${encodeURIComponent(id)}`),
|
|
25
|
+
api.get(`/eval3/units/span/${encodeURIComponent(id)}/verdicts`).catch(() => null),
|
|
26
|
+
]);
|
|
27
|
+
print({ ...detail, evaluations: unitVerdicts(verdicts) }, flags);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** trodo spans search TEXT [--limit 50]: spans whose name, input or output mention TEXT. */
|
|
31
|
+
async function search(words, flags) {
|
|
32
|
+
const q = words.join(' ').trim();
|
|
33
|
+
if (!q) throw usage('Usage: trodo spans search TEXT [--limit 50]');
|
|
34
|
+
const out = await client(config.resolve(flags)).get('/spans/search', { q, limit: int(flags.limit, 50, 200) });
|
|
35
|
+
print(out.spans || out, flags, [['span_id', 'Span', 36], ['name', 'Name', 28], ['kind', 'Kind', 8], ['started_at', 'Started', 24], ['run_id', 'Run', 36]]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { list, get, search };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const config = require('../config');
|
|
6
|
+
const { client } = require('../http');
|
|
7
|
+
const { print, sink } = require('../output');
|
|
8
|
+
const { specFromFlags, needsEvals } = require('../filters');
|
|
9
|
+
|
|
10
|
+
async function evalsIfNeeded(api, flags) {
|
|
11
|
+
return needsEvals(flags) ? (await api.get('/evals')).evals || [] : [];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function buildSpec(api, flags, kind) {
|
|
15
|
+
return specFromFlags(flags, { kind, evals: await evalsIfNeeded(api, flags) });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** trodo traces count [filter flags] */
|
|
19
|
+
async function count(_args, flags) {
|
|
20
|
+
const api = client(config.resolve(flags));
|
|
21
|
+
const spec = await buildSpec(api, flags, flags.kind || 'runs');
|
|
22
|
+
const out = await api.post('/traces/count', spec);
|
|
23
|
+
print({ kind: spec.kind, matched: out.matched, capped: out.capped, window: out.window, sample: out.sample }, flags);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* trodo traces export [filter flags] [--include-trace] [--format trodo|openai|dpo] [--target output|expected] [-o file]
|
|
28
|
+
* One JSON line per unit (the row a dataset would hold, with its evaluations),
|
|
29
|
+
* or one fine-tuning example per line. The summary goes to stderr.
|
|
30
|
+
*/
|
|
31
|
+
async function exportTraces(_args, flags) {
|
|
32
|
+
const api = client(config.resolve(flags));
|
|
33
|
+
const spec = await buildSpec(api, flags, flags.kind || 'runs');
|
|
34
|
+
const body = { ...spec };
|
|
35
|
+
if (flags.format) body.format = String(flags.format);
|
|
36
|
+
if (flags.target) body.target = String(flags.target);
|
|
37
|
+
const dir = flags.dir && flags.dir !== true ? String(flags.dir) : null;
|
|
38
|
+
if (dir && flags.out) throw Object.assign(new Error('Give -o FILE or --dir DIR, not both.'), { exitCode: 2 });
|
|
39
|
+
if (dir) fs.mkdirSync(dir, { recursive: true });
|
|
40
|
+
const out = dir ? null : sink(flags.out && flags.out !== true ? String(flags.out) : null);
|
|
41
|
+
let summary = null;
|
|
42
|
+
let n = 0;
|
|
43
|
+
for await (const line of api.lines('POST', '/traces/export', { body })) {
|
|
44
|
+
if (line && line._trodo) { summary = line._trodo; continue; }
|
|
45
|
+
n += 1;
|
|
46
|
+
if (dir) {
|
|
47
|
+
// One file per unit, named by what it is: run-<id>.json, span-<id>.json, conversation-<id>.json.
|
|
48
|
+
const src = line.source || {};
|
|
49
|
+
const kind = line.unit_kind || (spec.kind === 'eval_results' ? 'verdict' : spec.kind.replace(/s$/, ''));
|
|
50
|
+
const md = line.metadata || {};
|
|
51
|
+
const byKind = { span: src.span_id || md.span_id, conversation: src.conversation_id || md.conversation_id, verdict: md.eval_run_id || src.eval_run_id, run: src.run_id || md.run_id };
|
|
52
|
+
const id = byKind[kind] || src.span_id || src.run_id || src.conversation_id || String(n).padStart(5, '0');
|
|
53
|
+
fs.writeFileSync(path.join(dir, `${kind}-${String(id).replace(/[^\w.-]/g, '_')}.json`), `${JSON.stringify(line, null, 2)}\n`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop
|
|
57
|
+
await out.write(line);
|
|
58
|
+
}
|
|
59
|
+
if (out) await out.close();
|
|
60
|
+
if (summary) process.stderr.write(`${summary.rows} ${spec.kind}${summary.skipped ? `, ${summary.skipped} skipped (no input or no target)` : ''}${flags.out ? ` → ${flags.out}` : dir ? ` → ${dir}/` : ''}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* trodo traces stats [filter flags] [--compare]
|
|
65
|
+
* Count, error rate, latency p50/p95/p99, tokens, cost, feedback, eval
|
|
66
|
+
* outcomes and the busiest agents for what the filter matches. --compare puts
|
|
67
|
+
* the window just before it beside it (the last 7 days against the 7 before).
|
|
68
|
+
*/
|
|
69
|
+
async function stats(_args, flags) {
|
|
70
|
+
const api = client(config.resolve(flags));
|
|
71
|
+
const spec = await buildSpec(api, flags, flags.kind || 'runs');
|
|
72
|
+
const current = await api.post('/traces/stats', spec);
|
|
73
|
+
if (!flags.compare) { print(current, flags); return; }
|
|
74
|
+
const from = new Date(current.window.from).getTime();
|
|
75
|
+
const to = new Date(current.window.to).getTime();
|
|
76
|
+
const prevSpec = { ...spec, filter: { ...spec.filter, time: { from: new Date(from - (to - from)).toISOString(), to: new Date(from).toISOString() } } };
|
|
77
|
+
const previous = await api.post('/traces/stats', prevSpec);
|
|
78
|
+
const pct = (a, b) => (b ? Number((((a - b) / b) * 100).toFixed(1)) : null);
|
|
79
|
+
print({
|
|
80
|
+
current,
|
|
81
|
+
previous,
|
|
82
|
+
change_pct: {
|
|
83
|
+
units: pct(current.units, previous.units),
|
|
84
|
+
error_rate: current.error_rate != null && previous.error_rate != null ? Number(((current.error_rate - previous.error_rate) * 100).toFixed(2)) : null,
|
|
85
|
+
p50_ms: pct(current.latency_ms.p50, previous.latency_ms.p50),
|
|
86
|
+
p95_ms: pct(current.latency_ms.p95, previous.latency_ms.p95),
|
|
87
|
+
cost: pct(current.cost, previous.cost),
|
|
88
|
+
},
|
|
89
|
+
}, flags);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** trodo traces agents: the agent names traces carry (what --agent takes), with run counts. */
|
|
93
|
+
async function agents(_a, flags) {
|
|
94
|
+
const out = await client(config.resolve(flags)).get('/runs/agents');
|
|
95
|
+
print(out.agents || out, flags, [['agent_name', 'Agent', 40], ['run_count', 'Runs', 8], ['error_count', 'Errors', 8], ['total_cost', 'Cost', 12], ['last_seen_at', 'Last seen', 24]]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** trodo traces fields [--agent NAME]: the properties --where can name, run and span, fixed and custom. */
|
|
99
|
+
async function fields(_a, flags) {
|
|
100
|
+
const agent = flags.agent && flags.agent !== true ? [].concat(flags.agent)[0] : undefined;
|
|
101
|
+
print(await client(config.resolve(flags)).get('/runs/property-keys', { agent_name: agent }), flags);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** trodo traces values FIELD [--search TEXT] [--limit 50]: the values one property takes, most common first. */
|
|
105
|
+
async function values([field], flags) {
|
|
106
|
+
if (!field) throw Object.assign(new Error('Usage: trodo traces values FIELD [--search TEXT] (trodo traces fields lists them)'), { exitCode: 2 });
|
|
107
|
+
const agent = flags.agent && flags.agent !== true ? [].concat(flags.agent)[0] : undefined;
|
|
108
|
+
const out = await client(config.resolve(flags)).get('/runs/property-values', { field, q: flags.search !== true ? flags.search : undefined, agent_name: agent, limit: flags.limit });
|
|
109
|
+
print(out.values || out, flags, [['value', 'Value', 50], ['count', 'Count', 10]]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { count, export: exportTraces, stats, agents, fields, values, buildSpec };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
const { usage } = require('../common');
|
|
7
|
+
|
|
8
|
+
/** trodo users get DISTINCT_ID [--since 30d]: one user's runs, conversations, cost, evals and feedback, as the user drawer shows. */
|
|
9
|
+
async function get([id], flags) {
|
|
10
|
+
if (!id) throw usage('Usage: trodo users get DISTINCT_ID');
|
|
11
|
+
print(await client(config.resolve(flags)).get(`/runs/users/${encodeURIComponent(id)}/profile`, { period: flags.since !== true ? flags.since : undefined }), flags);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
module.exports = { get };
|