@indemn/cli 1.6.0 → 1.7.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +9 -4
- package/.github/workflows/ci.yml +10 -0
- package/.github/workflows/publish.yml +2 -2
- package/.mcp.json +1 -4
- package/CONTRIBUTING.md +3 -1
- package/README.md +69 -176
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +226 -18
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/analytics.d.ts +3 -0
- package/dist/cli/analytics.d.ts.map +1 -0
- package/dist/cli/analytics.js +133 -0
- package/dist/cli/analytics.js.map +1 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +62 -17
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/billing.d.ts +3 -0
- package/dist/cli/billing.d.ts.map +1 -0
- package/dist/cli/billing.js +23 -0
- package/dist/cli/billing.js.map +1 -0
- package/dist/cli/campaigns.d.ts +3 -0
- package/dist/cli/campaigns.d.ts.map +1 -0
- package/dist/cli/campaigns.js +34 -0
- package/dist/cli/campaigns.js.map +1 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +69 -102
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +71 -27
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/conversations.d.ts +3 -0
- package/dist/cli/conversations.d.ts.map +1 -0
- package/dist/cli/conversations.js +129 -0
- package/dist/cli/conversations.js.map +1 -0
- package/dist/cli/distribution.d.ts +3 -0
- package/dist/cli/distribution.d.ts.map +1 -0
- package/dist/cli/distribution.js +122 -0
- package/dist/cli/distribution.js.map +1 -0
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +315 -71
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/functions.d.ts.map +1 -1
- package/dist/cli/functions.js +109 -38
- package/dist/cli/functions.js.map +1 -1
- package/dist/cli/index.js +63 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +27 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +152 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/kb.d.ts.map +1 -1
- package/dist/cli/kb.js +141 -15
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/link.d.ts +21 -0
- package/dist/cli/link.d.ts.map +1 -0
- package/dist/cli/link.js +112 -0
- package/dist/cli/link.js.map +1 -0
- package/dist/cli/org.d.ts.map +1 -1
- package/dist/cli/org.js +79 -67
- package/dist/cli/org.js.map +1 -1
- package/dist/cli/platform.d.ts +3 -0
- package/dist/cli/platform.d.ts.map +1 -0
- package/dist/cli/platform.js +133 -0
- package/dist/cli/platform.js.map +1 -0
- package/dist/cli/tickets.d.ts +3 -0
- package/dist/cli/tickets.d.ts.map +1 -0
- package/dist/cli/tickets.js +125 -0
- package/dist/cli/tickets.js.map +1 -0
- package/dist/cli/utils/output.d.ts +15 -9
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +53 -46
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts +3 -0
- package/dist/cli/voice.d.ts.map +1 -0
- package/dist/cli/voice.js +206 -0
- package/dist/cli/voice.js.map +1 -0
- package/dist/core/diff.d.ts +16 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +57 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/org-resolver.d.ts +37 -0
- package/dist/core/org-resolver.d.ts.map +1 -0
- package/dist/core/org-resolver.js +73 -0
- package/dist/core/org-resolver.js.map +1 -0
- package/dist/core/runtime.d.ts +36 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +62 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/scope.d.ts +33 -0
- package/dist/core/scope.d.ts.map +1 -0
- package/dist/core/scope.js +37 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/mcp/registry.d.ts +67 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +195 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +87 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts +21 -0
- package/dist/mcp/tool-meta.d.ts.map +1 -0
- package/dist/mcp/tool-meta.js +203 -0
- package/dist/mcp/tool-meta.js.map +1 -0
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +1462 -215
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/agent-bundle.d.ts +99 -0
- package/dist/sdk/agent-bundle.d.ts.map +1 -0
- package/dist/sdk/agent-bundle.js +211 -0
- package/dist/sdk/agent-bundle.js.map +1 -0
- package/dist/sdk/agents.d.ts +54 -1
- package/dist/sdk/agents.d.ts.map +1 -1
- package/dist/sdk/agents.js +59 -5
- package/dist/sdk/agents.js.map +1 -1
- package/dist/sdk/analytics.d.ts +158 -0
- package/dist/sdk/analytics.d.ts.map +1 -0
- package/dist/sdk/analytics.js +185 -0
- package/dist/sdk/analytics.js.map +1 -0
- package/dist/sdk/auth.d.ts +34 -12
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +60 -77
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/billing.d.ts +25 -0
- package/dist/sdk/billing.d.ts.map +1 -0
- package/dist/sdk/billing.js +61 -0
- package/dist/sdk/billing.js.map +1 -0
- package/dist/sdk/campaigns.d.ts +35 -0
- package/dist/sdk/campaigns.d.ts.map +1 -0
- package/dist/sdk/campaigns.js +59 -0
- package/dist/sdk/campaigns.js.map +1 -0
- package/dist/sdk/chat.d.ts +25 -2
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +37 -10
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +93 -5
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +362 -100
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.d.ts +74 -0
- package/dist/sdk/config-change.d.ts.map +1 -0
- package/dist/sdk/config-change.js +117 -0
- package/dist/sdk/config-change.js.map +1 -0
- package/dist/sdk/config.d.ts.map +1 -1
- package/dist/sdk/config.js +2 -1
- package/dist/sdk/config.js.map +1 -1
- package/dist/sdk/conversations.d.ts +101 -0
- package/dist/sdk/conversations.d.ts.map +1 -0
- package/dist/sdk/conversations.js +155 -0
- package/dist/sdk/conversations.js.map +1 -0
- package/dist/sdk/credentials.d.ts +25 -0
- package/dist/sdk/credentials.d.ts.map +1 -0
- package/dist/sdk/credentials.js +44 -0
- package/dist/sdk/credentials.js.map +1 -0
- package/dist/sdk/distribution.d.ts +94 -0
- package/dist/sdk/distribution.d.ts.map +1 -0
- package/dist/sdk/distribution.js +100 -0
- package/dist/sdk/distribution.js.map +1 -0
- package/dist/sdk/eval.d.ts +81 -0
- package/dist/sdk/eval.d.ts.map +1 -1
- package/dist/sdk/eval.js +117 -0
- package/dist/sdk/eval.js.map +1 -1
- package/dist/sdk/functions.d.ts +14 -1
- package/dist/sdk/functions.d.ts.map +1 -1
- package/dist/sdk/functions.js +48 -5
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/jarvis-chat.d.ts +28 -0
- package/dist/sdk/jarvis-chat.d.ts.map +1 -0
- package/dist/sdk/jarvis-chat.js +117 -0
- package/dist/sdk/jarvis-chat.js.map +1 -0
- package/dist/sdk/kb.d.ts +69 -1
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +111 -24
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts +13 -0
- package/dist/sdk/orgs.d.ts.map +1 -0
- package/dist/sdk/orgs.js +59 -0
- package/dist/sdk/orgs.js.map +1 -0
- package/dist/sdk/platform.d.ts +62 -0
- package/dist/sdk/platform.d.ts.map +1 -0
- package/dist/sdk/platform.js +95 -0
- package/dist/sdk/platform.js.map +1 -0
- package/dist/sdk/session.d.ts +25 -0
- package/dist/sdk/session.d.ts.map +1 -0
- package/dist/sdk/session.js +69 -0
- package/dist/sdk/session.js.map +1 -0
- package/dist/sdk/skills.d.ts +46 -0
- package/dist/sdk/skills.d.ts.map +1 -0
- package/dist/sdk/skills.js +203 -0
- package/dist/sdk/skills.js.map +1 -0
- package/dist/sdk/test-chat.d.ts +52 -0
- package/dist/sdk/test-chat.d.ts.map +1 -0
- package/dist/sdk/test-chat.js +134 -0
- package/dist/sdk/test-chat.js.map +1 -0
- package/dist/sdk/tickets.d.ts +98 -0
- package/dist/sdk/tickets.d.ts.map +1 -0
- package/dist/sdk/tickets.js +91 -0
- package/dist/sdk/tickets.js.map +1 -0
- package/dist/sdk/types.d.ts +91 -6
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/voice.d.ts +147 -0
- package/dist/sdk/voice.d.ts.map +1 -0
- package/dist/sdk/voice.js +236 -0
- package/dist/sdk/voice.js.map +1 -0
- package/package.json +22 -3
- package/scripts/check-skills.mjs +25 -0
- package/skills/agent-builder/SKILL.md +89 -252
- package/skills/evaluations/SKILL.md +23 -234
- package/skills/evaluations/references/analysis.md +95 -0
- package/skills/evaluations/references/cli-reference.md +28 -0
- package/skills/evaluations/references/json-formats.md +100 -0
- package/src/cli/agents.ts +202 -22
- package/src/cli/analytics.ts +138 -0
- package/src/cli/auth.ts +56 -18
- package/src/cli/billing.ts +25 -0
- package/src/cli/campaigns.ts +33 -0
- package/src/cli/chat.ts +66 -115
- package/src/cli/config.ts +65 -31
- package/src/cli/conversations.ts +133 -0
- package/src/cli/distribution.ts +116 -0
- package/src/cli/eval.ts +254 -70
- package/src/cli/functions.ts +105 -39
- package/src/cli/index.ts +58 -55
- package/src/cli/init.ts +154 -0
- package/src/cli/kb.ts +120 -17
- package/src/cli/link.ts +117 -0
- package/src/cli/org.ts +81 -75
- package/src/cli/platform.ts +128 -0
- package/src/cli/tickets.ts +130 -0
- package/src/cli/utils/output.ts +51 -42
- package/src/cli/voice.ts +204 -0
- package/src/core/diff.ts +52 -0
- package/src/core/org-resolver.ts +113 -0
- package/src/core/runtime.ts +73 -0
- package/src/core/scope.ts +64 -0
- package/src/mcp/registry.ts +236 -0
- package/src/mcp/server.ts +84 -2
- package/src/mcp/tool-meta.ts +241 -0
- package/src/mcp/tools.ts +1529 -265
- package/src/sdk/agent-bundle.ts +245 -0
- package/src/sdk/agents.ts +74 -5
- package/src/sdk/analytics.ts +220 -0
- package/src/sdk/auth.ts +87 -107
- package/src/sdk/billing.ts +59 -0
- package/src/sdk/campaigns.ts +60 -0
- package/src/sdk/chat.ts +44 -10
- package/src/sdk/client.ts +373 -102
- package/src/sdk/config-change.ts +148 -0
- package/src/sdk/config.ts +2 -1
- package/src/sdk/conversations.ts +199 -0
- package/src/sdk/credentials.ts +53 -0
- package/src/sdk/distribution.ts +132 -0
- package/src/sdk/eval.ts +140 -0
- package/src/sdk/functions.ts +52 -6
- package/src/sdk/index.ts +2 -0
- package/src/sdk/jarvis-chat.ts +112 -0
- package/src/sdk/kb.ts +116 -29
- package/src/sdk/orgs.ts +62 -0
- package/src/sdk/platform.ts +115 -0
- package/src/sdk/session.ts +90 -0
- package/src/sdk/skills.ts +188 -0
- package/src/sdk/test-chat.ts +151 -0
- package/src/sdk/tickets.ts +120 -0
- package/src/sdk/types.ts +88 -6
- package/src/sdk/voice.ts +262 -0
- package/test/cli/entry.test.ts +34 -0
- package/test/cli/init.test.ts +66 -0
- package/test/cli/link.test.ts +57 -0
- package/test/core/org-resolver.test.ts +92 -0
- package/test/core/runtime.test.ts +30 -0
- package/test/core/scope.test.ts +93 -0
- package/test/harness.test.ts +24 -0
- package/test/helpers/config-home.ts +22 -0
- package/test/helpers/fake-middleware.ts +56 -0
- package/test/helpers/fetch-recorder.ts +75 -0
- package/test/helpers/jwt.ts +9 -0
- package/test/helpers/mcp-pair.ts +38 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
- package/test/mcp/askers.test.ts +60 -0
- package/test/mcp/elicit-timeout.test.ts +29 -0
- package/test/mcp/registry.test.ts +169 -0
- package/test/mcp/shaping.test.ts +41 -0
- package/test/mcp/tools-snapshot.test.ts +42 -0
- package/test/sdk/agents.test.ts +232 -0
- package/test/sdk/analytics.test.ts +89 -0
- package/test/sdk/auth.test.ts +70 -0
- package/test/sdk/billing.test.ts +47 -0
- package/test/sdk/campaigns.test.ts +43 -0
- package/test/sdk/client.test.ts +80 -0
- package/test/sdk/config-change.test.ts +142 -0
- package/test/sdk/conversations.test.ts +80 -0
- package/test/sdk/credentials.test.ts +33 -0
- package/test/sdk/distribution.test.ts +55 -0
- package/test/sdk/eval.test.ts +155 -0
- package/test/sdk/kb-upload.test.ts +50 -0
- package/test/sdk/kb.test.ts +95 -0
- package/test/sdk/orgs.test.ts +48 -0
- package/test/sdk/platform.test.ts +89 -0
- package/test/sdk/renewal.test.ts +115 -0
- package/test/sdk/session.test.ts +60 -0
- package/test/sdk/skills.test.ts +235 -0
- package/test/sdk/test-chat.test.ts +112 -0
- package/test/sdk/tickets.test.ts +42 -0
- package/test/sdk/voice.test.ts +147 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { IndemnClient } from './client.js';
|
|
2
|
+
import { CliError, EXIT } from '../core/runtime.js';
|
|
3
|
+
|
|
4
|
+
/** Collections of the V2 platform (Darwin) the CLI can read. */
|
|
5
|
+
export type PlatformResource = 'agents' | 'templates' | 'components' | 'connectors' | 'connector-bindings' | 'scenarios';
|
|
6
|
+
|
|
7
|
+
/** Which of them the CLI may change. Components and connectors are a global catalog with no auth on Darwin: read-only here. */
|
|
8
|
+
const WRITABLE: PlatformResource[] = ['agents', 'templates', 'connector-bindings', 'scenarios'];
|
|
9
|
+
|
|
10
|
+
export interface JarvisJob {
|
|
11
|
+
job_id: string;
|
|
12
|
+
status: 'pending' | 'running' | 'completed' | 'failed';
|
|
13
|
+
bot_ids?: string[];
|
|
14
|
+
progress?: { completed?: number; total?: number; results?: Array<{ bot_id: string; bot_name?: string; success: boolean; error?: string }> };
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertWritable(resource: PlatformResource): void {
|
|
19
|
+
if (!WRITABLE.includes(resource)) {
|
|
20
|
+
throw new CliError(`${resource} is a shared catalog on the V2 platform; the CLI only reads it.`, EXIT.NEEDS_INPUT);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The V2 platform (Darwin): V2 agents, templates, scenarios, simulations and Jarvis jobs, all checked by Darwin against your org and role. */
|
|
25
|
+
export class PlatformSDK {
|
|
26
|
+
constructor(private client: IndemnClient) {}
|
|
27
|
+
|
|
28
|
+
list(resource: PlatformResource, query?: Record<string, string>): Promise<unknown[]> {
|
|
29
|
+
return this.client.platformRequest('GET', `/${resource}`, { query });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get(resource: PlatformResource, id: string): Promise<Record<string, unknown>> {
|
|
33
|
+
return this.client.platformRequest('GET', `/${resource}/${encodeURIComponent(id)}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async create(resource: PlatformResource, body: Record<string, unknown>): Promise<unknown> {
|
|
37
|
+
assertWritable(resource);
|
|
38
|
+
return this.client.platformRequest('POST', `/${resource}`, { body });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async update(resource: PlatformResource, id: string, body: Record<string, unknown>): Promise<unknown> {
|
|
42
|
+
assertWritable(resource);
|
|
43
|
+
return this.client.platformRequest('PUT', `/${resource}/${encodeURIComponent(id)}`, { body });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async remove(resource: PlatformResource, id: string): Promise<unknown> {
|
|
47
|
+
assertWritable(resource);
|
|
48
|
+
return this.client.platformRequest('DELETE', `/${resource}/${encodeURIComponent(id)}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Checks a template's graph: components exist and edges point at real nodes. */
|
|
52
|
+
validateTemplate(id: string): Promise<{ valid: boolean; errors: string[]; warnings: string[] }> {
|
|
53
|
+
return this.client.platformRequest('POST', `/templates/${encodeURIComponent(id)}/validate`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Validates input against a connector's schema and previews the request; never calls the connector. */
|
|
57
|
+
testConnector(id: string, input: Record<string, unknown>): Promise<unknown> {
|
|
58
|
+
return this.client.platformRequest('POST', `/connectors/${encodeURIComponent(id)}/test`, { body: { test_input: input } });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run scenarios against a V2 agent. Darwin does not check the agent's org here, so the agent is read first (that read is org-checked). */
|
|
62
|
+
async runSimulation(agentId: string, scenarioIds: string[] = []): Promise<unknown> {
|
|
63
|
+
await this.get('agents', agentId);
|
|
64
|
+
return this.client.platformRequest('POST', '/simulations/run', { body: { agent_id: agentId, scenario_ids: scenarioIds, triggered_by: 'user' } });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
simulationJobs(filter: { agentId?: string; status?: string } = {}): Promise<unknown[]> {
|
|
68
|
+
const q: Record<string, string> = {};
|
|
69
|
+
if (filter.agentId) q.agent_id = filter.agentId;
|
|
70
|
+
if (filter.status) q.status = filter.status;
|
|
71
|
+
return this.client.platformRequest('GET', '/simulations/jobs', { query: q });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
simulationJob(id: string): Promise<unknown> {
|
|
75
|
+
return this.client.platformRequest('GET', `/simulations/jobs/${encodeURIComponent(id)}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
simulationResults(filter: { agentId?: string; resultId?: string; limit?: number } = {}): Promise<unknown> {
|
|
79
|
+
if (filter.resultId) return this.client.platformRequest('GET', `/simulations/results/${encodeURIComponent(filter.resultId)}`);
|
|
80
|
+
const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 10, 100)) };
|
|
81
|
+
if (filter.agentId) q.agent_id = filter.agentId;
|
|
82
|
+
return this.client.platformRequest('GET', '/simulations/results', { query: q });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Which agents of the org have a baseline evaluation, and which are stale. */
|
|
86
|
+
orgEvaluations(): Promise<unknown> {
|
|
87
|
+
return this.client.platformRequest('GET', '/org-evaluations');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Ask Jarvis to generate baseline evaluations for up to 50 agents (Darwin checks they are in the org). */
|
|
91
|
+
createJarvisJob(botIds: string[]): Promise<JarvisJob> {
|
|
92
|
+
if (botIds.length < 1 || botIds.length > 50) throw new CliError('Give 1 to 50 agent ids.', EXIT.NEEDS_INPUT);
|
|
93
|
+
return this.client.platformRequest('POST', '/jarvis/jobs', { body: { job_type: 'baseline_generation', bot_ids: botIds } });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
jarvisJobs(status?: string): Promise<JarvisJob[]> {
|
|
97
|
+
return this.client.platformRequest('GET', '/jarvis/jobs', { query: status ? { status } : undefined });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
jarvisJob(id: string): Promise<JarvisJob> {
|
|
101
|
+
return this.client.platformRequest('GET', `/jarvis/jobs/${encodeURIComponent(id)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Poll a Jarvis job until it completes (Darwin never marks jobs failed; per-agent failures are in progress.results). */
|
|
105
|
+
async waitForJarvisJob(id: string, opts: { intervalMs?: number; timeoutMs?: number; onProgress?: (j: JarvisJob) => void } = {}): Promise<JarvisJob> {
|
|
106
|
+
const started = Date.now();
|
|
107
|
+
for (;;) {
|
|
108
|
+
const job = await this.jarvisJob(id);
|
|
109
|
+
opts.onProgress?.(job);
|
|
110
|
+
if (job.status === 'completed' || job.status === 'failed') return job;
|
|
111
|
+
if (Date.now() - started > (opts.timeoutMs ?? 30 * 60_000)) throw new CliError(`Jarvis job ${id} is still ${job.status}.`);
|
|
112
|
+
await new Promise((r) => setTimeout(r, opts.intervalMs ?? 5_000));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Environment, StoredSession } from './types.js';
|
|
2
|
+
|
|
3
|
+
export type { StoredSession };
|
|
4
|
+
import { SessionExpiredError } from '../core/runtime.js';
|
|
5
|
+
import { FIREBASE_CONFIGS } from './auth.js';
|
|
6
|
+
|
|
7
|
+
const SECURE_TOKEN_URL = 'https://securetoken.googleapis.com/v1/token';
|
|
8
|
+
|
|
9
|
+
export type UnauthPost = <T>(path: string, body: unknown, headers?: Record<string, string>) => Promise<T>;
|
|
10
|
+
|
|
11
|
+
const RELOGIN = 'Run `indemn login` in a terminal, then retry.';
|
|
12
|
+
|
|
13
|
+
/** Expiry from the JWT `exp` claim, or null when the token has none. */
|
|
14
|
+
export function tokenExpiry(token: string): Date | null {
|
|
15
|
+
const payload = token.split('.')[1];
|
|
16
|
+
if (!payload) return null;
|
|
17
|
+
try {
|
|
18
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: number };
|
|
19
|
+
return typeof claims.exp === 'number' ? new Date(claims.exp * 1000) : null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Renew when less than `windowMs` (default one hour) of life is left. Unknown expiry never renews. */
|
|
26
|
+
export function needsRenewal(session: StoredSession, now: Date = new Date(), windowMs = 60 * 60 * 1000): boolean {
|
|
27
|
+
const expiry = session.expires_at ? new Date(session.expires_at) : tokenExpiry(session.token);
|
|
28
|
+
if (!expiry || Number.isNaN(expiry.getTime())) return false;
|
|
29
|
+
return expiry.getTime() - now.getTime() < windowMs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Trade the Firebase refresh token for a fresh Firebase ID token (Google secure-token API). */
|
|
33
|
+
export async function refreshIdToken(env: Environment, refreshToken: string): Promise<{ idToken: string; refreshToken: string }> {
|
|
34
|
+
const url = `${SECURE_TOKEN_URL}?key=${encodeURIComponent(FIREBASE_CONFIGS[env].apiKey)}`;
|
|
35
|
+
const res = await fetch(url, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
38
|
+
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken }).toString(),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
// Revoked, password changed or account disabled: a real login is the right answer.
|
|
42
|
+
throw new SessionExpiredError(`Session expired and could not be renewed. ${RELOGIN}`);
|
|
43
|
+
}
|
|
44
|
+
const json = await res.json() as { id_token?: string; refresh_token?: string };
|
|
45
|
+
if (!json.id_token) throw new SessionExpiredError(`Session renewal returned no token. ${RELOGIN}`);
|
|
46
|
+
return { idToken: json.id_token, refreshToken: json.refresh_token || refreshToken };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stripScheme(token: string): string {
|
|
50
|
+
return token.replace(/^JWT\s+/i, '');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Exchange a Firebase ID token for a copilot session, exactly as the dashboard
|
|
55
|
+
* does. Accounts with MFA get an emailed code; without a way to ask for it the
|
|
56
|
+
* caller must log in interactively.
|
|
57
|
+
*/
|
|
58
|
+
export async function exchangeIdToken(
|
|
59
|
+
post: UnauthPost,
|
|
60
|
+
idToken: string,
|
|
61
|
+
askMfaCode?: () => Promise<string>,
|
|
62
|
+
): Promise<{ token: string; userId: string; email?: string }> {
|
|
63
|
+
const signin = await post<{
|
|
64
|
+
requiresMfa?: boolean;
|
|
65
|
+
mfaSessionId?: string;
|
|
66
|
+
token?: string;
|
|
67
|
+
user?: { _id: string; email?: string };
|
|
68
|
+
}>('/auth/firebase/signin', { idToken });
|
|
69
|
+
|
|
70
|
+
if (signin.requiresMfa) {
|
|
71
|
+
if (!askMfaCode) {
|
|
72
|
+
throw new SessionExpiredError(`Your account needs an MFA code to renew the session. ${RELOGIN}`);
|
|
73
|
+
}
|
|
74
|
+
const code = (await askMfaCode()).trim();
|
|
75
|
+
const verified = await post<{ token: string; user: { _id: string; email?: string } }>(
|
|
76
|
+
'/auth/firebase/mfa/verify',
|
|
77
|
+
{ sessionId: signin.mfaSessionId, code },
|
|
78
|
+
);
|
|
79
|
+
return { token: stripScheme(verified.token), userId: verified.user._id, email: verified.user.email };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!signin.token || !signin.user) throw new SessionExpiredError(`Sign-in returned no session. ${RELOGIN}`);
|
|
83
|
+
return { token: stripScheme(signin.token), userId: signin.user._id, email: signin.user.email };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Package a copilot token for storage. */
|
|
87
|
+
export function toStoredSession(token: string, refreshToken?: string): StoredSession {
|
|
88
|
+
const expiry = tokenExpiry(token);
|
|
89
|
+
return { token, expires_at: expiry?.toISOString(), refresh_token: refreshToken };
|
|
90
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { CliError, EXIT } from '../core/runtime.js';
|
|
3
|
+
|
|
4
|
+
const FILLER_WORDS = new Set(['tool', 'tools', 'skill', 'skills', 'function', 'functions']);
|
|
5
|
+
/** Built-in skills a user skill must not imitate (copilot utils/constants.js INTERNAL_FUNCTIONS_LIST). */
|
|
6
|
+
const INTERNAL_SKILLS = ['faqs-retriever'];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* copilot's name skeleton (utils/stringUtil.js skillNameKey), reproduced so a
|
|
10
|
+
* clash is reported before anything is sent. "FAQs Retriever",
|
|
11
|
+
* "FQ retrievers" and "F.A.Q. retrieval tool" all become "fqrtrv".
|
|
12
|
+
*/
|
|
13
|
+
export function skillNameKey(name: string): string {
|
|
14
|
+
const joined = String(name || '')
|
|
15
|
+
.replace(/[.']/g, '')
|
|
16
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.split(/[^a-z]+/)
|
|
19
|
+
.filter((word) => word && !FILLER_WORDS.has(word))
|
|
20
|
+
.map((word) => (word.length > 4 ? word.replace(/(ers|er|als|al|ings|ing|e)$/, '') : word))
|
|
21
|
+
.sort()
|
|
22
|
+
.join('');
|
|
23
|
+
return joined.charAt(0) + joined.slice(1).replace(/[aeious]/g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Throw the same error copilot would, before any request, when a name imitates a built-in skill. */
|
|
27
|
+
export function assertSkillNameAllowed(name: string | undefined, type?: string): void {
|
|
28
|
+
if (!name || (type && INTERNAL_SKILLS.includes(type))) return;
|
|
29
|
+
const key = skillNameKey(name);
|
|
30
|
+
if (INTERNAL_SKILLS.some((reserved) => skillNameKey(reserved) === key)) {
|
|
31
|
+
throw new CliError(
|
|
32
|
+
`"${name}" is too similar to the built-in skill "${INTERNAL_SKILLS.join('", "')}", which every agent already has. Choose a different name.`,
|
|
33
|
+
EXIT.NEEDS_INPUT,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let stdinCache: string | undefined;
|
|
39
|
+
function readStdin(): string {
|
|
40
|
+
if (stdinCache === undefined) stdinCache = readFileSync(0, 'utf8');
|
|
41
|
+
return stdinCache;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A secret passed to a flag, kept out of shell history when possible:
|
|
46
|
+
* "@path" reads a file, "env:NAME" reads an environment variable, "-" reads
|
|
47
|
+
* stdin. A plain value still works but prints a warning.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSecretArg(flag: string, value: string | undefined, warn: (msg: string) => void = (m) => process.stderr.write(m + '\n')): string | undefined {
|
|
50
|
+
if (value === undefined) return undefined;
|
|
51
|
+
if (value === '-') return readStdin().trim();
|
|
52
|
+
if (value.startsWith('@')) {
|
|
53
|
+
try {
|
|
54
|
+
return readFileSync(value.slice(1), 'utf8').trim();
|
|
55
|
+
} catch {
|
|
56
|
+
throw new CliError(`${flag}: cannot read ${value.slice(1)}.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (value.startsWith('env:')) {
|
|
60
|
+
const name = value.slice(4);
|
|
61
|
+
const v = process.env[name];
|
|
62
|
+
if (v === undefined || v === '') throw new CliError(`${flag}: environment variable ${name} is not set.`);
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
warn(`Warning: ${flag} was typed on the command line and is now in your shell history. Next time use ${flag} @file, ${flag} env:NAME or ${flag} - (stdin).`);
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Find the list of proposed skills in a generate-from-doc answer. */
|
|
70
|
+
/**
|
|
71
|
+
* A skill's configurationSchema from plain fields, laid over `base` (the saved one on update,
|
|
72
|
+
* since copilot replaces it whole). copilot needs an authorization type on rest_api skills;
|
|
73
|
+
* unauthenticated APIs get no-auth.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* A call-transfer destination the way the dashboard saves it: phoneNumber (+ intlTelObj) or sipUri
|
|
77
|
+
* (+ sipHeaders). Older skills say handoffNumber, and some exports carry the runtime's
|
|
78
|
+
* {"action":"transfer","payload":{"to":...}} as payload. copilot reads sipUri, then phoneNumber, then
|
|
79
|
+
* handoffNumber, so a new destination in `patch` must replace the saved one of either kind.
|
|
80
|
+
*/
|
|
81
|
+
export function transferDestination(base: Record<string, unknown>, patch: Record<string, unknown> = {}): Record<string, unknown> {
|
|
82
|
+
const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
|
|
83
|
+
const legacyTo = (c: Record<string, unknown>) => {
|
|
84
|
+
if (typeof c.payload !== 'string') return undefined;
|
|
85
|
+
try {
|
|
86
|
+
const p = JSON.parse(c.payload) as { action?: string; payload?: { to?: unknown } };
|
|
87
|
+
return p?.action === 'transfer' ? str(p.payload?.to) : undefined;
|
|
88
|
+
} catch { return undefined; }
|
|
89
|
+
};
|
|
90
|
+
const pick = (c: Record<string, unknown>) => {
|
|
91
|
+
const to = legacyTo(c);
|
|
92
|
+
const sip = str(c.sipUri) ?? (to?.startsWith('sip:') ? to : undefined);
|
|
93
|
+
const phone = str(c.phoneNumber) ?? str(c.handoffNumber) ?? (to && !to.startsWith('sip:') ? to : undefined);
|
|
94
|
+
return sip ? { sip, headers: c.sipHeaders } : phone ? { phone } : undefined;
|
|
95
|
+
};
|
|
96
|
+
const dest = pick(patch) ?? pick(base);
|
|
97
|
+
const out: Record<string, unknown> = { ...base, ...patch };
|
|
98
|
+
if (legacyTo(out) !== undefined) { delete out.payload; delete out.payloadOps; }
|
|
99
|
+
for (const k of ['sipUri', 'sipHeaders', 'phoneNumber', 'intlTelObj', 'handoffNumber']) delete out[k];
|
|
100
|
+
if (dest && 'sip' in dest) {
|
|
101
|
+
out.sipUri = dest.sip;
|
|
102
|
+
if (dest.headers && typeof dest.headers === 'object' && Object.keys(dest.headers).length) out.sipHeaders = dest.headers;
|
|
103
|
+
} else if (dest?.phone) {
|
|
104
|
+
out.phoneNumber = dest.phone;
|
|
105
|
+
out.intlTelObj = { number: dest.phone, e164Number: dest.phone };
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The dashboard's REST form quotes a bare {{parameter}} before saving (rest-api-form.tsx quoteBareMentions):
|
|
112
|
+
* `"guests": {{guests}}` becomes `"guests": "{{guests}}"`, so the body is the valid JSON copilot requires.
|
|
113
|
+
*/
|
|
114
|
+
export function quoteBareMentions(payload: string): string {
|
|
115
|
+
let out = '';
|
|
116
|
+
let inString = false;
|
|
117
|
+
for (let i = 0; i < payload.length; i++) {
|
|
118
|
+
const ch = payload[i];
|
|
119
|
+
if (inString) {
|
|
120
|
+
out += ch;
|
|
121
|
+
if (ch === '\\') out += payload[++i] ?? '';
|
|
122
|
+
else if (ch === '"') inString = false;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (ch === '"') {
|
|
126
|
+
inString = true;
|
|
127
|
+
} else if (ch === '{' && payload[i + 1] === '{') {
|
|
128
|
+
const close = payload.indexOf('}}', i + 2);
|
|
129
|
+
if (close !== -1) {
|
|
130
|
+
out += JSON.stringify(payload.slice(i, close + 2));
|
|
131
|
+
i = close + 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
out += ch;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A REST body as copilot saves it: JSON text with bare placeholders quoted; refused when it is still not JSON. */
|
|
141
|
+
export function requestBody(payload: unknown): string {
|
|
142
|
+
if (typeof payload !== 'string') return JSON.stringify(payload);
|
|
143
|
+
if (!payload.trim()) return payload;
|
|
144
|
+
const body = quoteBareMentions(payload);
|
|
145
|
+
try {
|
|
146
|
+
JSON.parse(body);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new CliError(`The request body is not valid JSON (${err instanceof Error ? err.message : String(err)}). Nothing was saved. Send it as JSON text, e.g. {"event_date": "{{event_date}}"}.`, EXIT.ERROR);
|
|
149
|
+
}
|
|
150
|
+
return body;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function skillConfiguration(
|
|
154
|
+
type: string,
|
|
155
|
+
f: {
|
|
156
|
+
url?: string; method?: string; payload?: unknown; response_field?: string;
|
|
157
|
+
output_instructions?: string; craft_response?: boolean; configuration?: Record<string, unknown>;
|
|
158
|
+
transfer_to?: string;
|
|
159
|
+
},
|
|
160
|
+
base: Record<string, unknown> = {},
|
|
161
|
+
): Record<string, unknown> | undefined {
|
|
162
|
+
const patch: Record<string, unknown> = { ...(f.configuration ?? {}) };
|
|
163
|
+
if (f.url !== undefined) patch.endpoint = f.url;
|
|
164
|
+
if (f.method !== undefined) patch.method = f.method.toUpperCase();
|
|
165
|
+
if (f.payload !== undefined) patch.payload = f.payload;
|
|
166
|
+
if (patch.payload !== undefined) patch.payload = requestBody(patch.payload);
|
|
167
|
+
if (f.response_field !== undefined) patch.responseField = f.response_field;
|
|
168
|
+
if (f.output_instructions !== undefined) patch.outputInstructions = f.output_instructions;
|
|
169
|
+
if (f.craft_response !== undefined) patch.craftResponse = f.craft_response;
|
|
170
|
+
if (f.transfer_to !== undefined) patch[f.transfer_to.startsWith('sip:') ? 'sipUri' : 'phoneNumber'] = f.transfer_to;
|
|
171
|
+
if (!Object.keys(patch).length) return undefined;
|
|
172
|
+
if (type === 'transfer_call') return transferDestination(base, patch);
|
|
173
|
+
const merged = { ...base, ...patch };
|
|
174
|
+
if (type === 'rest_api' && merged.authorization === undefined) merged.authorization = { type: 'no-auth' };
|
|
175
|
+
return merged;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function proposedSkills(res: unknown): Array<Record<string, unknown>> | null {
|
|
179
|
+
if (Array.isArray(res)) return res as Array<Record<string, unknown>>;
|
|
180
|
+
if (res && typeof res === 'object') {
|
|
181
|
+
const r = res as Record<string, unknown>;
|
|
182
|
+
for (const key of ['functions', 'skills', 'tools', 'data', 'result']) {
|
|
183
|
+
const found = proposedSkills(r[key]);
|
|
184
|
+
if (found) return found;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { IndemnClient } from './client.js';
|
|
2
|
+
import { AgentsSDK } from './agents.js';
|
|
3
|
+
import { ConfigSDK } from './config.js';
|
|
4
|
+
import { ChatSession } from './chat.js';
|
|
5
|
+
import { ConversationsSDK, conversationUrl, idOf } from './conversations.js';
|
|
6
|
+
import type { Agent } from './types.js';
|
|
7
|
+
|
|
8
|
+
/** Who is testing: the login email, else the email inside a host-supplied session token. */
|
|
9
|
+
export function testerIdentity(client: IndemnClient): string | null {
|
|
10
|
+
const cred = client.getCredential();
|
|
11
|
+
if (cred?.source === 'config') {
|
|
12
|
+
const email = IndemnClient.loadConfig()?.email;
|
|
13
|
+
if (email) return email;
|
|
14
|
+
}
|
|
15
|
+
if (cred?.kind === 'token') {
|
|
16
|
+
try {
|
|
17
|
+
const claims = JSON.parse(Buffer.from(cred.token.split('.')[1] ?? '', 'base64url').toString('utf8')) as { email?: string };
|
|
18
|
+
if (claims.email) return claims.email;
|
|
19
|
+
} catch {
|
|
20
|
+
// not a readable JWT
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The dashboard's "Talk to agent" preview record for this person and agent.
|
|
28
|
+
* Reused when it exists (never overwritten, it may hold context they saved in
|
|
29
|
+
* the dashboard); created empty otherwise. Optional: failure only means the
|
|
30
|
+
* dashboard cannot show the tester card.
|
|
31
|
+
*/
|
|
32
|
+
export async function ensurePreview(client: IndemnClient, agentId: string): Promise<string | null> {
|
|
33
|
+
const path = `/organization/:org_id/ai-studio/bots/${agentId}/preview/agent`;
|
|
34
|
+
try {
|
|
35
|
+
const existing = await client.copilotGet<{ _id?: string } | null>(path);
|
|
36
|
+
if (existing && existing._id) return String(existing._id);
|
|
37
|
+
const created = await client.copilotPost<{ _id?: string }>(path, {});
|
|
38
|
+
return created?._id ? String(created._id) : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface OpenedTestChat {
|
|
45
|
+
session: ChatSession;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
agent: Agent;
|
|
48
|
+
orgId: string;
|
|
49
|
+
projectId: string;
|
|
50
|
+
url: string;
|
|
51
|
+
tester: string | null;
|
|
52
|
+
preview: string | null;
|
|
53
|
+
resumed: boolean;
|
|
54
|
+
/** Every complete agent message so far, greeting included. */
|
|
55
|
+
transcript: string[];
|
|
56
|
+
/** True while the agent is streaming a message. */
|
|
57
|
+
isReceiving: () => boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Open a chat that is guaranteed to be a test conversation in the chosen org:
|
|
62
|
+
* the agent is fetched through copilot (which refuses an agent from another
|
|
63
|
+
* org), the session is opened with isTestMode, and the stored conversation is
|
|
64
|
+
* read back before the caller may send anything.
|
|
65
|
+
*/
|
|
66
|
+
export async function openTestChat(
|
|
67
|
+
client: IndemnClient,
|
|
68
|
+
agentId: string,
|
|
69
|
+
opts: { sessionId?: string; verifyTimeoutMs?: number; onSession?: (session: ChatSession) => void } = {},
|
|
70
|
+
): Promise<OpenedTestChat> {
|
|
71
|
+
const orgId = await client.currentOrgId();
|
|
72
|
+
const agent = await new AgentsSDK(client).getAgent(agentId);
|
|
73
|
+
if (!agent) throw new Error(`Agent ${agentId} not found in this org.`);
|
|
74
|
+
const projectId = idOf(agent.id_project);
|
|
75
|
+
const tester = testerIdentity(client);
|
|
76
|
+
const preview = opts.sessionId ? null : await ensurePreview(client, agent._id);
|
|
77
|
+
// The middleware greets with customData.first_message, else a stock line; the dashboard preview sends the saved one.
|
|
78
|
+
const firstMessage = opts.sessionId ? undefined : (await new ConfigSDK(client).getConfig(agent._id).catch(() => undefined))?.first_message;
|
|
79
|
+
const customData = firstMessage?.text ? { first_message: firstMessage } : undefined;
|
|
80
|
+
|
|
81
|
+
const session = new ChatSession(agent._id, client.getHosts().middleware, opts.sessionId, { tester, preview, customData });
|
|
82
|
+
const transcript: string[] = [];
|
|
83
|
+
let receiving = false;
|
|
84
|
+
session.on('stream', (data: { isFirstChunk?: boolean; streamFinished?: boolean }) => {
|
|
85
|
+
if (data.isFirstChunk) receiving = true;
|
|
86
|
+
if (data.streamFinished) receiving = false;
|
|
87
|
+
});
|
|
88
|
+
session.on('message', (text: string) => transcript.push(text));
|
|
89
|
+
opts.onSession?.(session);
|
|
90
|
+
const sessionId = await session.connect();
|
|
91
|
+
try {
|
|
92
|
+
if (!projectId) throw new Error(`Agent ${agent._id} has no project; cannot confirm the conversation is a test. Nothing was sent.`);
|
|
93
|
+
await new ConversationsSDK(client).verifyTestConversation(projectId, sessionId, orgId, { timeoutMs: opts.verifyTimeoutMs });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
await session.disconnect();
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
session,
|
|
100
|
+
sessionId,
|
|
101
|
+
agent,
|
|
102
|
+
orgId,
|
|
103
|
+
projectId,
|
|
104
|
+
url: conversationUrl(client, projectId, sessionId),
|
|
105
|
+
tester,
|
|
106
|
+
preview,
|
|
107
|
+
resumed: Boolean(opts.sessionId),
|
|
108
|
+
transcript,
|
|
109
|
+
isReceiving: () => receiving,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function until(check: () => boolean, timeoutMs: number, stepMs = 100): Promise<boolean> {
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const started = Date.now();
|
|
116
|
+
const tick = () => {
|
|
117
|
+
if (check()) return resolve(true);
|
|
118
|
+
if (Date.now() - started >= timeoutMs) return resolve(false);
|
|
119
|
+
setTimeout(tick, stepMs);
|
|
120
|
+
};
|
|
121
|
+
tick();
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Send one message and collect the reply. A new conversation first waits for
|
|
127
|
+
* the greeting. The reply is complete when the agent has been quiet for
|
|
128
|
+
* `settleMs` after its last message.
|
|
129
|
+
*/
|
|
130
|
+
export async function exchange(
|
|
131
|
+
chat: OpenedTestChat,
|
|
132
|
+
message: string,
|
|
133
|
+
opts: { greetingWaitMs?: number; timeoutMs?: number; settleMs?: number } = {},
|
|
134
|
+
): Promise<{ greeting: string; response: string }> {
|
|
135
|
+
if (!chat.resumed) {
|
|
136
|
+
await until(() => chat.transcript.length > 0 && !chat.isReceiving(), opts.greetingWaitMs ?? 8_000);
|
|
137
|
+
}
|
|
138
|
+
const greeting = chat.transcript.join('\n');
|
|
139
|
+
const start = chat.transcript.length;
|
|
140
|
+
chat.session.send(message);
|
|
141
|
+
|
|
142
|
+
const gotReply = await until(() => chat.transcript.length > start && !chat.isReceiving(), opts.timeoutMs ?? 60_000);
|
|
143
|
+
if (!gotReply) throw new Error(`Agent did not respond within ${Math.round((opts.timeoutMs ?? 60_000) / 1000)} seconds.`);
|
|
144
|
+
let seen = chat.transcript.length;
|
|
145
|
+
for (;;) {
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? 1_500));
|
|
147
|
+
if (chat.transcript.length === seen && !chat.isReceiving()) break;
|
|
148
|
+
seen = chat.transcript.length;
|
|
149
|
+
}
|
|
150
|
+
return { greeting, response: chat.transcript.slice(start).join('\n') };
|
|
151
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { IndemnClient } from './client.js';
|
|
2
|
+
import { CliError, EXIT } from '../core/runtime.js';
|
|
3
|
+
|
|
4
|
+
export type TicketStatus = 'not_started' | 'in_progress' | 'completed';
|
|
5
|
+
export type TicketPriority = 'urgent' | 'high' | 'normal' | 'low';
|
|
6
|
+
|
|
7
|
+
export interface Ticket {
|
|
8
|
+
_id: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
status?: TicketStatus;
|
|
11
|
+
priority?: TicketPriority;
|
|
12
|
+
ticket_ref?: string;
|
|
13
|
+
assignee?: unknown;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TicketInput {
|
|
18
|
+
title: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
notes?: string;
|
|
21
|
+
category?: string;
|
|
22
|
+
priority?: TicketPriority;
|
|
23
|
+
due_date?: string;
|
|
24
|
+
tags?: string[];
|
|
25
|
+
assignee?: { type: string; target?: string; user_id?: string } | null;
|
|
26
|
+
conversations?: Array<{ request_id: string; summary?: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type CreateResult =
|
|
30
|
+
| { saved: true; ticket: Ticket }
|
|
31
|
+
| { saved: false; similar: Ticket[]; message: string };
|
|
32
|
+
|
|
33
|
+
/** Tickets and the rules that create them. Both need tickets turned on for the org. */
|
|
34
|
+
export class TicketsSDK {
|
|
35
|
+
constructor(private client: IndemnClient) {}
|
|
36
|
+
|
|
37
|
+
list(filter: { status?: TicketStatus[]; source?: string; assignee?: string; search?: string; limit?: number; skip?: number } = {}): Promise<Ticket[]> {
|
|
38
|
+
const q: Record<string, string> = { limit: String(Math.min(filter.limit ?? 50, 500)), skip: String(filter.skip ?? 0) };
|
|
39
|
+
if (filter.status?.length) q.status = filter.status.join(',');
|
|
40
|
+
if (filter.source) q.source = filter.source;
|
|
41
|
+
if (filter.assignee) q.assignee = filter.assignee;
|
|
42
|
+
if (filter.search) q.q = filter.search;
|
|
43
|
+
return this.client.copilotGet('/organization/:org_id/tickets', q);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get(id: string): Promise<Ticket> {
|
|
47
|
+
return this.client.copilotGet(`/organization/:org_id/tickets/${id}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
byConversation(requestId: string): Promise<Ticket[]> {
|
|
51
|
+
return this.client.copilotGet(`/organization/:org_id/tickets/by-request/${encodeURIComponent(requestId)}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a ticket. Without `force`, copilot first looks for similar open
|
|
56
|
+
* tickets (an LLM call) and stores nothing when it finds some.
|
|
57
|
+
*/
|
|
58
|
+
async create(input: TicketInput, opts: { force?: boolean } = {}): Promise<CreateResult> {
|
|
59
|
+
if (!input.title?.trim()) throw new CliError('A ticket needs a title.', EXIT.NEEDS_INPUT);
|
|
60
|
+
const res = await this.client.copilotPost<Ticket | { saved?: boolean; similar?: Ticket[] }>('/organization/:org_id/tickets', { ...input, ...(opts.force ? { force: true } : {}) });
|
|
61
|
+
const maybe = res as { saved?: boolean; similar?: Ticket[] };
|
|
62
|
+
if (maybe.saved === false && Array.isArray(maybe.similar)) {
|
|
63
|
+
return { saved: false, similar: maybe.similar, message: 'Similar tickets exist; nothing was created. Link to one of them, or create anyway with force.' };
|
|
64
|
+
}
|
|
65
|
+
return { saved: true, ticket: res as Ticket };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
update(id: string, changes: Partial<Pick<TicketInput, 'title' | 'description' | 'notes' | 'category' | 'priority' | 'due_date' | 'tags'>> & { status?: TicketStatus }): Promise<Ticket> {
|
|
69
|
+
return this.client.copilotPut(`/organization/:org_id/tickets/${id}`, changes);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Assign to a member (`{ type: "user", target: userId }`), another assignee type, or null to unassign. */
|
|
73
|
+
assign(id: string, assignee: { type: string; target: string } | null): Promise<Ticket> {
|
|
74
|
+
return this.client.copilotPost(`/organization/:org_id/tickets/${id}/assign`, { assignee });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Close a ticket, optionally adding a note. */
|
|
78
|
+
resolve(id: string, note?: string): Promise<Ticket> {
|
|
79
|
+
return this.client.copilotPost(`/organization/:org_id/tickets/${id}/resolve`, note ? { note } : {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
addConversation(id: string, requestId: string, summary?: string): Promise<Ticket> {
|
|
83
|
+
return this.client.copilotPost(`/organization/:org_id/tickets/${id}/conversations`, { conversation: { request_id: requestId }, ...(summary ? { summary } : {}) });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
link(id: string, relatedId: string): Promise<Ticket> {
|
|
87
|
+
return this.client.copilotPost(`/organization/:org_id/tickets/${id}/link`, { related_ticket_id: relatedId });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
archive(id: string): Promise<unknown> {
|
|
91
|
+
return this.client.copilotDelete(`/organization/:org_id/tickets/${id}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
listRules(opts: { agentId?: string; includeTrashed?: boolean } = {}): Promise<unknown[]> {
|
|
95
|
+
const q: Record<string, string> = {};
|
|
96
|
+
if (opts.agentId) q.agent = opts.agentId;
|
|
97
|
+
if (opts.includeTrashed) q.includeTrashed = 'true';
|
|
98
|
+
return this.client.copilotGet('/organization/:org_id/ticket-creation-rules', q);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create or change a rule. A new rule without `force` is first checked by an
|
|
103
|
+
* LLM for overlaps; when it has caveats nothing is stored.
|
|
104
|
+
*/
|
|
105
|
+
async saveRule(input: { ruleId?: string; name?: string; prompt?: string; assignment?: unknown; scope?: unknown; enabled?: boolean }, opts: { force?: boolean } = {}): Promise<{ saved: boolean; rule?: unknown; caveats?: unknown[]; suggested_name?: string }> {
|
|
106
|
+
const body: Record<string, unknown> = {};
|
|
107
|
+
for (const k of ['name', 'prompt', 'assignment', 'scope', 'enabled'] as const) if (input[k] !== undefined) body[k] = input[k];
|
|
108
|
+
if (input.ruleId) {
|
|
109
|
+
return { saved: true, rule: await this.client.copilotPut(`/organization/:org_id/ticket-creation-rules/${input.ruleId}`, body) };
|
|
110
|
+
}
|
|
111
|
+
if (!input.prompt) throw new CliError('A new rule needs a prompt: when should a ticket be created?', EXIT.NEEDS_INPUT);
|
|
112
|
+
const res = await this.client.copilotPost<{ saved?: boolean; caveats?: unknown[]; suggested_name?: string }>('/organization/:org_id/ticket-creation-rules', { ...body, ...(opts.force ? { force: true } : {}) });
|
|
113
|
+
if (res && res.saved === false) return { saved: false, caveats: res.caveats, suggested_name: res.suggested_name };
|
|
114
|
+
return { saved: true, rule: res };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
deleteRule(ruleId: string): Promise<unknown> {
|
|
118
|
+
return this.client.copilotDelete(`/organization/:org_id/ticket-creation-rules/${ruleId}`);
|
|
119
|
+
}
|
|
120
|
+
}
|