@ethogram/cli 0.1.0-alpha.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.
@@ -0,0 +1,373 @@
1
+ import { readdir, readFile, realpath, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { build } from 'esbuild';
4
+ import { ExternalEvidenceValidationError, normalizeExternalExecutionEvidence, } from './external-evidence.js';
5
+ export class TypeScriptAdapterError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = 'TypeScriptAdapterError';
11
+ }
12
+ }
13
+ function isRecord(value) {
14
+ return Boolean(value && typeof value === 'object');
15
+ }
16
+ function isAgent(value) {
17
+ return isRecord(value)
18
+ && typeof value.id === 'string'
19
+ && typeof value.name === 'string'
20
+ && typeof value.description === 'string'
21
+ && typeof value.icon === 'string';
22
+ }
23
+ function isGivenValue(value, ancestors) {
24
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
25
+ return true;
26
+ if (typeof value === 'number')
27
+ return Number.isFinite(value);
28
+ if (typeof value !== 'object' || ancestors.has(value))
29
+ return false;
30
+ ancestors.add(value);
31
+ try {
32
+ if (Array.isArray(value)) {
33
+ return value.every((entry, index) => Object.prototype.hasOwnProperty.call(value, index) && isGivenValue(entry, ancestors));
34
+ }
35
+ const prototype = Object.getPrototypeOf(value);
36
+ if (prototype !== Object.prototype && prototype !== null)
37
+ return false;
38
+ return Reflect.ownKeys(value).every((key) => {
39
+ if (typeof key !== 'string')
40
+ return false;
41
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
42
+ return Boolean(descriptor && !descriptor.get && !descriptor.set && isGivenValue(descriptor.value, ancestors));
43
+ });
44
+ }
45
+ finally {
46
+ ancestors.delete(value);
47
+ }
48
+ }
49
+ function isGiven(value) {
50
+ return Array.isArray(value)
51
+ ? value.every((entry) => typeof entry === 'string')
52
+ : isGivenValue(value, new Set()) && Boolean(value && typeof value === 'object' && !Array.isArray(value));
53
+ }
54
+ function isStory(value) {
55
+ return isRecord(value)
56
+ && value.__ethogramType === 'story'
57
+ && typeof value.id === 'string'
58
+ && typeof value.name === 'string'
59
+ && isAgent(value.agent)
60
+ && typeof value.description === 'string'
61
+ && isGiven(value.given)
62
+ && typeof value.prompt === 'string'
63
+ && Array.isArray(value.expectations);
64
+ }
65
+ function isProfile(value) {
66
+ return isRecord(value)
67
+ && value.__ethogramType === 'execution-profile'
68
+ && typeof value.id === 'string'
69
+ && isRecord(value.tools)
70
+ && typeof value.execute === 'function';
71
+ }
72
+ function cloneRecord(value) {
73
+ return structuredClone(value);
74
+ }
75
+ function deepFreeze(value) {
76
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
77
+ Object.freeze(value);
78
+ for (const nested of Object.values(value))
79
+ deepFreeze(nested);
80
+ }
81
+ return value;
82
+ }
83
+ async function allFiles(root, directories) {
84
+ const files = [];
85
+ async function visit(directory) {
86
+ let entries;
87
+ try {
88
+ entries = await readdir(directory, { withFileTypes: true });
89
+ }
90
+ catch {
91
+ return;
92
+ }
93
+ for (const entry of entries) {
94
+ if (entry.name === 'node_modules' || entry.name === '.git')
95
+ continue;
96
+ const absolute = path.join(directory, entry.name);
97
+ if (entry.isDirectory())
98
+ await visit(absolute);
99
+ else
100
+ files.push(absolute);
101
+ }
102
+ }
103
+ for (const directory of directories)
104
+ await visit(path.resolve(root, directory));
105
+ return [...new Set(files)].sort();
106
+ }
107
+ function matches(filePath, stem) {
108
+ return ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].some((extension) => filePath.endsWith(`${stem}${extension}`));
109
+ }
110
+ async function importNativeModule(filePath) {
111
+ try {
112
+ const result = await build({
113
+ entryPoints: [filePath],
114
+ bundle: true,
115
+ platform: 'node',
116
+ format: 'esm',
117
+ target: 'node20',
118
+ write: false,
119
+ sourcemap: false,
120
+ logLevel: 'silent',
121
+ });
122
+ const source = result.outputFiles[0]?.contents;
123
+ if (!source)
124
+ throw new Error('The TypeScript adapter produced no executable module.');
125
+ const moduleUrl = `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
126
+ return await import(moduleUrl);
127
+ }
128
+ catch (error) {
129
+ const detail = error instanceof Error ? error.message.split('\n')[0] : 'Unknown module-loading error.';
130
+ throw new TypeScriptAdapterError(filePath.includes('.stories.') ? 'INVALID_STORY_EXPORT' : 'INVALID_EXECUTION_PROFILE_EXPORT', `Could not load ${filePath}: ${detail}`);
131
+ }
132
+ }
133
+ function uniqueById(values, code, label) {
134
+ const seen = new Set();
135
+ for (const item of values) {
136
+ if (seen.has(item.value.id))
137
+ throw new TypeScriptAdapterError(code, `Duplicate ${label} identity: ${item.value.id}`);
138
+ seen.add(item.value.id);
139
+ }
140
+ return values;
141
+ }
142
+ function relative(root, filePath) {
143
+ return path.relative(root, filePath).split(path.sep).join('/');
144
+ }
145
+ function toToolCall(invocation) {
146
+ const base = {
147
+ callId: invocation.callId,
148
+ name: invocation.name,
149
+ duration: `${invocation.durationMs}ms`,
150
+ input: JSON.stringify(invocation.input),
151
+ startedAt: invocation.startedAt,
152
+ endedAt: invocation.endedAt,
153
+ };
154
+ return invocation.status === 'success'
155
+ ? { ...base, status: 'success', output: JSON.stringify(invocation.output) }
156
+ : { ...base, status: 'error', ...(invocation.error === undefined ? {} : { error: invocation.error }) };
157
+ }
158
+ export class TypeScriptAdapter {
159
+ id = 'typescript';
160
+ bindings = new Map();
161
+ async loadProject(projectRoot) {
162
+ let root;
163
+ try {
164
+ root = await realpath(projectRoot);
165
+ if (!(await stat(root)).isDirectory())
166
+ throw new Error('not-directory');
167
+ }
168
+ catch {
169
+ throw new TypeScriptAdapterError('INVALID_PROJECT_ROOT', `Project root is not a readable directory: ${path.resolve(projectRoot)}`);
170
+ }
171
+ let packageName;
172
+ try {
173
+ const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
174
+ if (typeof packageJson.name !== 'string' || !packageJson.name.trim())
175
+ throw new Error('missing-name');
176
+ packageName = packageJson.name;
177
+ }
178
+ catch {
179
+ throw new TypeScriptAdapterError('MISSING_PROJECT_PACKAGE', `Project ${root} must contain a package.json with a name.`);
180
+ }
181
+ const configPath = path.join(root, 'ethogram.config.mjs');
182
+ try {
183
+ if (!(await stat(configPath)).isFile())
184
+ throw new Error('not-file');
185
+ }
186
+ catch {
187
+ throw new TypeScriptAdapterError('MISSING_ETHOGRAM_CONFIG', `Project ${root} is not initialized. Run "ethogram init" first.`);
188
+ }
189
+ let config;
190
+ try {
191
+ const module = await importNativeModule(configPath);
192
+ if (!isRecord(module.default))
193
+ throw new Error('missing-default');
194
+ config = module.default;
195
+ }
196
+ catch (error) {
197
+ if (error instanceof TypeScriptAdapterError) {
198
+ throw new TypeScriptAdapterError('INVALID_ETHOGRAM_CONFIG', `Invalid ethogram.config.mjs in ${root}: ${error.message}`);
199
+ }
200
+ throw new TypeScriptAdapterError('INVALID_ETHOGRAM_CONFIG', `Invalid ethogram.config.mjs in ${root}.`);
201
+ }
202
+ const agentDirectories = config.agentDirectories ?? ['agents'];
203
+ const storyDirectories = config.storyDirectories ?? ['stories'];
204
+ const executionDirectories = config.executionDirectories ?? ['execution'];
205
+ if (![agentDirectories, storyDirectories, executionDirectories].every((entries) => Array.isArray(entries) && entries.every((entry) => typeof entry === 'string' && entry.length > 0))) {
206
+ throw new TypeScriptAdapterError('INVALID_ETHOGRAM_CONFIG', 'Ethogram directory configuration must contain string arrays.');
207
+ }
208
+ const [agentFiles, storyFiles, profileFiles] = await Promise.all([
209
+ allFiles(root, agentDirectories),
210
+ allFiles(root, storyDirectories),
211
+ allFiles(root, executionDirectories),
212
+ ]);
213
+ const selectedAgentFiles = agentFiles.filter((file) => matches(file, '.agent'));
214
+ const selectedStoryFiles = storyFiles.filter((file) => matches(file, '.agent.stories'));
215
+ const selectedProfileFiles = profileFiles.filter((file) => matches(file, '.profile') || matches(file, '-profile'));
216
+ const agentEntries = (await Promise.all(selectedAgentFiles.map(async (file) => {
217
+ const module = await importNativeModule(file);
218
+ const values = Object.values(module).filter(isAgent);
219
+ if (values.length === 0) {
220
+ throw new TypeScriptAdapterError('INVALID_AGENT_EXPORT', `No valid Agent export found in ${relative(root, file)}.`);
221
+ }
222
+ return values.map((value) => ({ value, source: relative(root, file) }));
223
+ }))).flat();
224
+ const storyEntries = (await Promise.all(selectedStoryFiles.map(async (file) => {
225
+ const module = await importNativeModule(file);
226
+ const values = Object.values(module).filter(isStory);
227
+ if (values.length === 0) {
228
+ throw new TypeScriptAdapterError('INVALID_STORY_EXPORT', `No valid Story export found in ${relative(root, file)}.`);
229
+ }
230
+ return values.map((value) => ({ value, source: relative(root, file) }));
231
+ }))).flat();
232
+ const profileEntries = (await Promise.all(selectedProfileFiles.map(async (file) => {
233
+ const module = await importNativeModule(file);
234
+ const values = Object.values(module).filter(isProfile);
235
+ if (values.length === 0) {
236
+ throw new TypeScriptAdapterError('INVALID_EXECUTION_PROFILE_EXPORT', `No valid execution profile export found in ${relative(root, file)}.`);
237
+ }
238
+ return values.map((value) => ({ value, source: relative(root, file) }));
239
+ }))).flat();
240
+ const agents = uniqueById(agentEntries, 'DUPLICATE_AGENT_ID', 'Agent');
241
+ const stories = uniqueById(storyEntries, 'DUPLICATE_STORY_ID', 'Story');
242
+ const profiles = uniqueById(profileEntries, 'DUPLICATE_EXECUTION_PROFILE_ID', 'execution profile');
243
+ if (stories.length === 0)
244
+ throw new TypeScriptAdapterError('NO_STORIES', `No Ethogram Stories were found in ${root}.`);
245
+ const agentIds = new Set(agents.map(({ value }) => value.id));
246
+ const profilesById = new Map(profiles.map(({ value }) => [value.id, value]));
247
+ this.bindings = new Map();
248
+ const descriptors = stories.map(({ value: story, source }) => {
249
+ deepFreeze(story);
250
+ if (!agentIds.has(story.agent.id)) {
251
+ throw new TypeScriptAdapterError('UNKNOWN_STORY_AGENT', `Story ${story.id} references unknown Agent ${story.agent.id}.`);
252
+ }
253
+ const profileId = story.execution?.profile;
254
+ const profile = profileId ? profilesById.get(profileId) : undefined;
255
+ if (!profile) {
256
+ throw new TypeScriptAdapterError('UNKNOWN_EXECUTION_PROFILE', `Story ${story.id} references unavailable execution profile ${profileId ?? '(none)'}.`);
257
+ }
258
+ this.bindings.set(story.id, { story, profile });
259
+ return structuredClone({
260
+ id: story.id,
261
+ name: story.name,
262
+ agent: story.agent,
263
+ description: story.description,
264
+ given: story.given,
265
+ prompt: story.prompt,
266
+ expectations: story.expectations,
267
+ source,
268
+ executable: true,
269
+ });
270
+ });
271
+ return {
272
+ projectRoot: root,
273
+ name: typeof config.name === 'string' && config.name.trim() ? config.name : packageName,
274
+ adapter: { id: this.id, label: 'TypeScript' },
275
+ agents: agents.map(({ value }) => structuredClone(value)),
276
+ stories: descriptors,
277
+ };
278
+ }
279
+ async run(request) {
280
+ const binding = this.bindings.get(request.story.id);
281
+ if (!binding)
282
+ throw new TypeScriptAdapterError('UNKNOWN_EXECUTION_PROFILE', `No TypeScript binding exists for ${request.story.id}.`);
283
+ const trace = [];
284
+ const startedAtMs = Date.now();
285
+ try {
286
+ const outcome = await binding.profile.execute({
287
+ story: binding.story,
288
+ callTool: async (name, input) => {
289
+ const tool = binding.profile.tools[name];
290
+ if (!tool)
291
+ throw new Error(`Execution profile requested unavailable tool: ${name}`);
292
+ const callStartedMs = Date.now();
293
+ const invocation = {
294
+ callId: `typescript-${trace.length + 1}`,
295
+ name,
296
+ input: cloneRecord(input),
297
+ status: 'success',
298
+ startedAt: new Date(callStartedMs).toISOString(),
299
+ endedAt: '',
300
+ durationMs: 0,
301
+ };
302
+ trace.push(invocation);
303
+ try {
304
+ const output = await tool.execute(cloneRecord(input));
305
+ invocation.output = cloneRecord(output);
306
+ return cloneRecord(output);
307
+ }
308
+ catch (error) {
309
+ invocation.status = 'error';
310
+ invocation.output = undefined;
311
+ invocation.error = error instanceof Error
312
+ ? { name: error.name, message: error.message }
313
+ : { message: 'The tool execution failed.' };
314
+ throw error;
315
+ }
316
+ finally {
317
+ const endedAtMs = Date.now();
318
+ invocation.endedAt = new Date(endedAtMs).toISOString();
319
+ invocation.durationMs = Math.max(0, endedAtMs - callStartedMs);
320
+ }
321
+ },
322
+ });
323
+ if (outcome.evidence !== undefined && trace.length > 0) {
324
+ throw new TypeScriptAdapterError('CONFLICTING_OBSERVATION_SOURCES', 'CONFLICTING_OBSERVATION_SOURCES: A Run cannot use both Ethogram callTool evidence and external execution evidence.');
325
+ }
326
+ const endedAtMs = Date.now();
327
+ if (outcome.evidence !== undefined) {
328
+ let normalized;
329
+ try {
330
+ normalized = normalizeExternalExecutionEvidence(outcome.evidence);
331
+ }
332
+ catch (error) {
333
+ if (error instanceof ExternalEvidenceValidationError) {
334
+ throw new TypeScriptAdapterError(error.code, error.message);
335
+ }
336
+ throw error;
337
+ }
338
+ return {
339
+ decision: outcome.decision,
340
+ reason: outcome.finalResponse,
341
+ finalResponse: outcome.finalResponse,
342
+ ...normalized,
343
+ };
344
+ }
345
+ return {
346
+ decision: outcome.decision,
347
+ reason: outcome.finalResponse,
348
+ finalResponse: outcome.finalResponse,
349
+ toolCalls: trace.map(toToolCall),
350
+ timeline: trace.map((invocation) => ({
351
+ label: `Tool completed: ${invocation.name}`,
352
+ detail: `Operational status: ${invocation.status}`,
353
+ duration: `${invocation.durationMs}ms`,
354
+ })),
355
+ evidence: {
356
+ provider: 'local-typescript-adapter',
357
+ model: 'offline-deterministic-profile',
358
+ startedAt: new Date(startedAtMs).toISOString(),
359
+ endedAt: new Date(endedAtMs).toISOString(),
360
+ latencyMs: Math.max(0, endedAtMs - startedAtMs),
361
+ finishReason: 'completed',
362
+ tokenUsage: { availability: 'unavailable' },
363
+ },
364
+ };
365
+ }
366
+ catch (error) {
367
+ if (error instanceof TypeScriptAdapterError)
368
+ throw error;
369
+ const detail = error instanceof Error ? error.message : 'Unknown profile execution error.';
370
+ throw new TypeScriptAdapterError('PROFILE_EXECUTION_FAILED', `TypeScript execution profile failed: ${detail}`);
371
+ }
372
+ }
373
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@ethogram/cli",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Local Ethogram initialization and read-only developer runtime.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/leonardocamacho1983/ethogram.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "homepage": "https://github.com/leonardocamacho1983/ethogram#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/leonardocamacho1983/ethogram/issues"
14
+ },
15
+ "keywords": ["ai-agents", "behavioral-testing", "typescript", "cli"],
16
+ "type": "module",
17
+ "bin": {
18
+ "ethogram": "./dist/cli.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "README.md"
23
+ ],
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "engines": {
28
+ "node": ">=20.9"
29
+ },
30
+ "dependencies": {
31
+ "@ethogram/core": "0.1.0-alpha.0",
32
+ "esbuild": "^0.28.2"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc -p tsconfig.build.json && node scripts/copy-runtime.mjs"
36
+ }
37
+ }