diff --git a/CHANGELOG.md b/CHANGELOG.md index e3e96c3..01722e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.6.5] - 2026-03-31 + +### Added + +- `atomemo-plugin-development` skill 模板新增评估配置 `evals.json` + +### Changed + +- 更新 `atomemo-plugin-development` 声明式参数相关参考文档(`credential.md`、`declarative-parameters-examples.md`、`declarative-parameters-property-base.md`、`declarative-parameters-resource-mapper.md`) +- 更新 `src/templates/common/skills-lock.json`,与 skill 模板引用保持一致 + ## [0.6.4] - 2026-03-26 ### Added @@ -312,7 +323,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Code quality checks with Biome. - Automated release workflow via GitHub Actions. -[Unreleased]: https://github.com/choice-open/atomemo-plugin-cli/compare/v0.6.4...HEAD +[Unreleased]: https://github.com/choice-open/atomemo-plugin-cli/compare/v0.6.5...HEAD +[0.6.5]: https://github.com/choice-open/atomemo-plugin-cli/compare/v0.6.4...v0.6.5 [0.6.4]: https://github.com/choice-open/atomemo-plugin-cli/compare/v0.6.3...v0.6.4 [0.6.3]: https://github.com/choice-open/atomemo-plugin-cli/compare/v0.6.2...v0.6.3 [0.6.2]: https://github.com/choice-open/atomemo-plugin-cli/compare/v0.6.1...v0.6.2 diff --git a/package-lock.json b/package-lock.json index 35e3d0a..4f2e5df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@choiceopen/atomemo-plugin-cli", - "version": "0.6.4", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@choiceopen/atomemo-plugin-cli", - "version": "0.6.4", + "version": "0.6.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6662d4c..5104371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@choiceopen/atomemo-plugin-cli", - "version": "0.6.4", + "version": "0.6.5", "description": "A command-line utility for building and publishing Choiceform Atomemo Plugin.", "keywords": [ "oclif" diff --git a/src/templates/common/.agents/skills/atomemo-plugin-development/evals/evals.json b/src/templates/common/.agents/skills/atomemo-plugin-development/evals/evals.json new file mode 100644 index 0000000..7cabcd9 --- /dev/null +++ b/src/templates/common/.agents/skills/atomemo-plugin-development/evals/evals.json @@ -0,0 +1,289 @@ +{ + "skill_name": "atomemo-plugin-development", + "evals": [ + { + "id": 1, + "prompt": "我开发完了一个 Atomemo 插件,叫 `weather-tool`,功能是查询天气。我怎么把它发布到官方插件市场?请帮我走一遍完整流程。", + "expected_output": "完整的首次发布流程:发布前检查清单 → bun run release → Fork 官方仓库 → 添加插件代码 → 提交 PR(标题格式 feat(plugin): add weather-tool)→ 等待 CI 和团队审核", + "files": [], + "assertions": [ + { + "name": "提及 bun run release 命令", + "description": "输出中包含 `bun run release` 命令" + }, + { + "name": "提及 Fork 官方仓库", + "description": "输出中提到需要 Fork atomemo-official-plugins 仓库" + }, + { + "name": "提及 PR 标题格式", + "description": "输出中包含 PR 标题格式 feat(plugin): add " + }, + { + "name": "提及发布前检查清单", + "description": "输出中包含至少 3 项发布前检查项(metadata/security/README/version)" + } + ] + }, + { + "id": 2, + "prompt": "我的 `weather-tool` 插件之前已经发布了 v1.0.0,现在加了一个新功能想发布 v1.1.0,更新流程是什么?", + "expected_output": "更新已发布插件的流程:在 package.json 中更新版本号 → 完成代码更改 → 运行 bun run release → 提交包含更新插件的新 PR", + "files": [], + "assertions": [ + { + "name": "提及更新 package.json 版本号", + "description": "输出中提到需要在 package.json 中更新版本号" + }, + { + "name": "提及 bun run release 命令", + "description": "输出中包含 `bun run release` 命令" + }, + { + "name": "提及提交新 PR", + "description": "输出中提到需要提交新的 PR(而不是更新旧 PR)" + } + ] + }, + { + "id": 3, + "prompt": "我的插件代码写完了,准备发布,但我担心有遗漏。能帮我做一个发布前的完整检查清单并逐项确认吗?", + "expected_output": "完整的发布前检查清单,包含:1) package.json 元数据(name/version/description/author)2) 代码质量(无调试日志/无 lint 错误)3) 安全(无硬编码 API key,使用 Credentials 系统)4) README 清晰说明 5) 版本号已更新", + "files": [], + "assertions": [ + { + "name": "提及 package.json 元数据检查", + "description": "输出中包含 name, version, description, author 等元数据检查" + }, + { + "name": "提及安全检查(无硬编码 secrets)", + "description": "输出中提到不得有硬编码的 API key 或 secret,应使用 Credentials 系统" + }, + { + "name": "提及 README 检查", + "description": "输出中提到需要有清晰的 README 说明插件功能和配置" + }, + { + "name": "提及代码质量检查", + "description": "输出中提到无调试日志、无 linting 错误等代码质量要求" + } + ] + }, + { + "id": 4, + "prompt": "我想给 Atomemo 做一个插件,可以查询当地天气,然后在工作流里用,从哪里开始?", + "expected_output": "新项目完整起步流程:安装 atomemo-plugin-cli → atomemo auth login(需用户手动完成 OAuth)→ atomemo plugin init → atomemo plugin refresh-key → bun install → bun run dev → 在 src/tools/ 中创建 Tool 定义文件并实现 ToolDefinition 接口 → 在 src/index.ts 注册", + "files": [], + "assertions": [ + { + "name": "提及安装 CLI", + "description": "输出中包含安装 @choiceopen/atomemo-plugin-cli 的步骤" + }, + { + "name": "提及 atomemo auth login", + "description": "输出中提到 atomemo auth login 且说明需要用户手动完成" + }, + { + "name": "提及 atomemo plugin init", + "description": "输出中包含 atomemo plugin init 命令" + }, + { + "name": "明确推荐 Tool 类型", + "description": "输出中说明天气查询应做成 Tool 插件,并提及 src/tools/ 目录" + } + ] + }, + { + "id": 5, + "prompt": "atomemo plugin init 之后下一步是什么?需要手动改哪些文件?", + "expected_output": "init 后的步骤:atomemo plugin refresh-key 生成 .env(含 debug API key)→ bun install 安装依赖 → bun run dev 启动开发模式,成功连接显示 status: ok → 项目结构介绍(src/tools, src/models, src/credentials, src/index.ts)→ 在对应目录创建实现文件并在 index.ts 注册", + "files": [], + "assertions": [ + { + "name": "提及 atomemo plugin refresh-key", + "description": "输出中包含 atomemo plugin refresh-key 命令" + }, + { + "name": "提及 bun install 和 bun run dev", + "description": "输出中包含 bun install 和 bun run dev 两个命令" + }, + { + "name": "提及成功连接的标志", + "description": "输出中提到成功连接 Hub 后显示 status: ok 或 success: true" + }, + { + "name": "说明需在 src/index.ts 注册", + "description": "输出中提到每个 tool/model/credential 都需要在 index.ts 中注册" + } + ] + }, + { + "id": 6, + "prompt": "我想把 DeepSeek-R1 接入 Atomemo,作为一个可选的模型,应该怎么定义?", + "expected_output": "在 src/models/ 创建 model 定义文件,实现 ModelDefinition 接口,关键字段包括 name(格式为 provider/model_name,如 deepseek/deepseek-r1)、display_name、description、capabilities、pricing 等,最后在 src/index.ts 中调用 plugin.addModel() 注册", + "files": [], + "assertions": [ + { + "name": "提及 src/models/ 目录", + "description": "输出中提到在 src/models/ 目录下创建文件" + }, + { + "name": "提及 ModelDefinition 接口", + "description": "输出中提到 ModelDefinition 类型或接口" + }, + { + "name": "提及 model name 格式", + "description": "输出中提到 model 命名需遵循 provider/model_name 格式" + }, + { + "name": "提及 plugin.addModel() 注册", + "description": "输出中包含 plugin.addModel() 调用" + } + ] + }, + { + "id": 7, + "prompt": "我的 Atomemo 插件需要让用户填写 API Key,怎么做才安全?有专门的凭据系统吗?", + "expected_output": "使用 Credential 系统:在 src/credentials/ 创建 CredentialDefinition 文件声明凭据字段(如 api_key)→ 在 Tool 的 parameters 中添加 type: 'credential_id' 参数引用凭据 → 在 invoke 函数中通过 args.credentials[name] 访问 → 在 index.ts 注册。明确说明不得硬编码 API key", + "files": [], + "assertions": [ + { + "name": "提及 CredentialDefinition", + "description": "输出中提到 CredentialDefinition 接口或类型" + }, + { + "name": "明确禁止硬编码 API key", + "description": "输出中明确说明不得将 API key 硬编码在代码中" + }, + { + "name": "提及 credential_id 参数类型", + "description": "输出中提到在 parameters 中使用 type: 'credential_id' 来引用凭据" + }, + { + "name": "提及 src/credentials/ 目录", + "description": "输出中提到在 src/credentials/ 目录下定义凭据" + } + ] + }, + { + "id": 8, + "prompt": "bun run dev 跑起来之后一直显示 status not ok,我的 plugin hub 连接有问题,怎么排查?", + "expected_output": "最常见原因是 debug API key 过期(24h 有效期)。排查步骤:1) 检查 .env 文件中的 key 是否存在 2) 运行 atomemo plugin refresh-key 刷新 key 3) 重新 bun run dev,成功连接显示 status: ok, response: { success: true }。其他可能:网络问题、Hub 服务状态。", + "files": [], + "assertions": [ + { + "name": "提及 debug API key 有效期", + "description": "输出中提到 debug API key 在 24 小时后过期" + }, + { + "name": "提及 atomemo plugin refresh-key", + "description": "输出中包含 atomemo plugin refresh-key 命令" + }, + { + "name": "提及检查 .env 文件", + "description": "输出中提到检查 .env 文件中的 key 配置" + }, + { + "name": "提及成功连接的标志", + "description": "输出中提到成功连接后显示 status: ok 或 success: true" + } + ] + }, + { + "id": 9, + "prompt": "我想在 Atomemo 里加一个能发钉钉消息的功能,不知道是做 tool 还是 model,有什么区别?", + "expected_output": "发钉钉消息应做成 Tool(src/tools/)而非 Model。Tool 用于调用外部 API 或执行业务逻辑(如发消息、查数据、触发操作);Model 专门用于描述 LLM(大语言模型)的集成,包括其能力、定价、参数等。发钉钉消息是典型的 Tool 使用场景。", + "files": [], + "assertions": [ + { + "name": "明确推荐 Tool 类型", + "description": "输出中明确说明发钉钉消息应做成 Tool,而非 Model" + }, + { + "name": "说明 Tool 的用途", + "description": "输出中解释 Tool 用于调用外部 API 或执行业务逻辑" + }, + { + "name": "说明 Model 的用途", + "description": "输出中解释 Model 专用于描述 LLM 集成" + }, + { + "name": "提及 src/tools/ 目录", + "description": "输出中提到 Tool 实现放在 src/tools/ 目录" + } + ] + }, + { + "id": 10, + "prompt": "参数配置那里我想做个下拉框让用户选 region,有哪些参数类型可以用?", + "expected_output": "下拉框使用 type: 'string' + enum 字段列出选项 + ui.component: 'select'。同时介绍其他参数类型:string(input/textarea)、number(number-input)、boolean(switch/checkbox)、credential_id(凭据选择器)等,并说明 ui 字段控制渲染方式。", + "files": [], + "assertions": [ + { + "name": "提及 enum 字段", + "description": "输出中提到使用 enum 字段列出下拉选项" + }, + { + "name": "提及 ui.component: select", + "description": "输出中提到 ui: { component: 'select' } 配置" + }, + { + "name": "提及 type: string", + "description": "输出中提到下拉框参数的 type 为 string" + }, + { + "name": "介绍其他可用参数类型", + "description": "输出中列举了至少 2 种其他参数类型(number、boolean、credential_id 等)" + } + ] + }, + { + "id": 11, + "prompt": "I need to build a custom tool for Atomemo that wraps our internal REST API. What's the recommended project structure?", + "expected_output": "Recommended structure: use atomemo plugin init to scaffold the project, implement the tool in src/tools/.ts using the ToolDefinition interface, register in src/index.ts. If the API requires auth, define credentials in src/credentials/. Use TypeScript for best SDK support. Key fields: name, display_name, description, parameters, invoke function.", + "files": [], + "assertions": [ + { + "name": "Mentions src/tools/ directory", + "description": "Output references src/tools/ as the location for Tool implementations" + }, + { + "name": "Mentions ToolDefinition interface", + "description": "Output mentions implementing the ToolDefinition interface" + }, + { + "name": "Mentions registration in index.ts", + "description": "Output includes plugin.addTool() registration step in src/index.ts" + }, + { + "name": "Mentions invoke function", + "description": "Output describes the invoke async function that contains the business logic" + } + ] + }, + { + "id": 12, + "prompt": "插件写完了,现在要发布到官方市场,PR 提交到哪个仓库?标题有格式要求吗?", + "expected_output": "PR 提交到 atomemo-official-plugins 仓库(需先 Fork)。PR 标题格式:feat(plugin): add ,例如 feat(plugin): add weather-lookup。还需要将插件代码放到 plugins// 目录下。", + "files": [], + "assertions": [ + { + "name": "提及 atomemo-official-plugins 仓库", + "description": "输出中提到目标仓库是 atomemo-official-plugins" + }, + { + "name": "提及 Fork 仓库步骤", + "description": "输出中提到需要先 Fork 再提 PR" + }, + { + "name": "提及 PR 标题格式", + "description": "输出中包含 feat(plugin): add 格式的 PR 标题" + }, + { + "name": "提及 plugins/ 目录结构", + "description": "输出中提到插件代码放在 plugins// 目录下" + } + ] + } + ] +} diff --git a/src/templates/common/.agents/skills/atomemo-plugin-development/references/credential.md b/src/templates/common/.agents/skills/atomemo-plugin-development/references/credential.md index ab9f073..c12fdc8 100644 --- a/src/templates/common/.agents/skills/atomemo-plugin-development/references/credential.md +++ b/src/templates/common/.agents/skills/atomemo-plugin-development/references/credential.md @@ -135,6 +135,8 @@ If a provider uses OAuth2, set `oauth2: true` and include the required storage f - `refresh_token` (`encrypted_string`) - `expires_at` (`integer`) +Keep these token fields in the credential `parameters` definition even if you do not expose them in the UI — the Hub needs them to persist OAuth tokens. + You must also implement: 1. `oauth2_build_authorize_url` diff --git a/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-examples.md b/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-examples.md index ef6e4d8..6bf15d8 100644 --- a/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-examples.md +++ b/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-examples.md @@ -380,6 +380,9 @@ resource_mapping: { display_name: { en_US: column.name }, type: column.type, required: column.required, + ui: { + hint: column.description ? { en_US: column.description } : null, + }, })), } }, diff --git a/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-property-base.md b/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-property-base.md index d80f70c..da1c7f7 100644 --- a/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-property-base.md +++ b/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-property-base.md @@ -1,32 +1,52 @@ # PropertyBase — Common Base Fields -All property types share these base fields: +All Property types share the following base fields: ```typescript interface PropertyBase { + /** Required. Unique identifier for the parameter, used for data access. */ name: string + + /** Optional. Label name displayed in the form. */ display_name?: I18nText + + /** Optional. Whether the parameter is required. Default: false. */ required?: boolean + + /** Optional. Conditional show/hide rules. */ display?: { - show?: DisplayCondition - hide?: DisplayCondition + show?: DisplayCondition // Show when condition is true + hide?: DisplayCondition // Hide when condition is true } + + /** Optional. AI-related configuration. */ ai?: { + /** Parameter description for LLM, helping AI understand parameter purpose. */ llm_description?: I18nText } + + /** Optional. UI component configuration. Different types support different UI components. */ ui?: PropertyUI + + /** Optional. Upstream parameter dependencies for cascading resource fields. */ + depends_on?: string[] + + /** Optional. Hub-side decoder applied before the value reaches your plugin. */ + decoder?: "json" } ``` -## Field Details +**Field Description**: -| Field | Required | Description | -| ----- | -------- | ----------- | -| `name` | ✅ | Parameter identifier. Uses letters, digits, and underscores | -| `display_name` | ❌ | Label shown in the UI | -| `required` | ❌ | Marks the field as required | -| `display` | ❌ | Conditional show/hide rules | -| `ai` | ❌ | Extra hints for LLM-assisted filling | -| `ui` | ❌ | Rendering configuration | +| Field | Required | Description | +| -------------- | -------- | ----------------------------------------------------------------------------------- | +| `name` | ✅ | Parameter path identifier. Only alphanumeric and underscore. | +| `display_name` | ❌ | Form label. Uses humanized `name` if not provided. | +| `required` | ❌ | Mark as required; form will show required indicator. | +| `display` | ❌ | Conditional visibility. See [Section 9](./declarative-parameters-display-condition.md). | +| `ai` | ❌ | Parameter description when AI Agent calls tool. | +| `ui` | ❌ | Uses type's default UI component if not provided. | +| `depends_on` | ❌ | Upstream property names for cascading behavior. Currently supported on `resource_locator` and `resource_mapper`. | +| `decoder` | ❌ | Hub-side decoder. Currently only `"json"` is supported, and it is intended for object properties. | -See `declarative-parameters-display-condition.md` for conditional visibility details. +> `depends_on` is only supported on `resource_locator` and `resource_mapper` today. Use `decoder: "json"` when the Hub should parse an incoming JSON string into an object before invoking your plugin. diff --git a/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-resource-mapper.md b/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-resource-mapper.md index 40c8517..06df5bb 100644 --- a/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-resource-mapper.md +++ b/src/templates/common/.agents/skills/atomemo-plugin-development/references/declarative-parameters-resource-mapper.md @@ -1,30 +1,70 @@ # Resource Mapper -`resource_mapper` lets users map a remote resource schema to custom values. It supports: +`PropertyResourceMapper` lets users map a remote resource's field schema to custom +values. It supports two modes: -- **manual mode**: one typed input per field -- **auto mode**: JSON editor with expression support +- **manual mode**: a dynamic form where each field gets a typed input +- **auto mode**: a JSON editor with expression support for programmatic mapping ## Property Definition ```typescript interface PropertyResourceMapper extends PropertyBase { type: "resource_mapper" + + /** Name of the method in resource_mapping used to fetch available fields. */ mapping_method: string + + /** Default value. */ default?: ResourceMapperValue | null } ``` +> [!NOTE] +> `resource_mapper` is only supported in **Tool** parameters. + ## Runtime Value ```typescript interface ResourceMapperValue { __type__: "resource_mapper" + + /** The mode that was active when this value was set. */ mapping_mode: "manual" | "auto" + + /** + * Manual mode: Record — one entry per mapped field. + * Auto mode: a JSON object or a string. + */ value: Record | string | null } ``` +## `ToolResourceMappingField` + +Each field returned by a `resource_mapping` callback uses this structure: + +```typescript +interface ToolResourceMappingField { + /** Unique field identifier, used as the key in the mapped value. */ + id: string + + /** Human-readable label shown in the UI; falls back to id if not set. */ + display_name?: I18nText | null + + /** Field value type — determines which input component is rendered in manual mode. */ + type: "string" | "number" | "integer" | "boolean" | "object" | "array" + + /** Whether the field is required. Required fields cannot be removed in manual mode. */ + required?: boolean | null + + /** Optional field-level hint shown next to the input field in manual mode. */ + ui?: { + hint?: I18nText | null + } | null +} +``` + ## `resource_mapping` Callback ```typescript @@ -34,16 +74,115 @@ type ToolResourceMappingFunction = (input: { credentials: Record } }) => Promise<{ - fields: Array<{ - id: string - display_name?: I18nText | null - type: "string" | "number" | "integer" | "boolean" | "object" | "array" - required?: boolean | null - }> + fields: Array empty_fields_notice?: I18nText | null }> ``` +Register the callback in `ToolDefinition.resource_mapping`: + +```typescript +const myTool: ToolDefinition = { + name: "my-tool", + parameters: [/* ... */], + resource_mapping: { + map_record_fields: async ({ args }) => { + const tableId = extractResourceLocator(args.parameters.table) + if (!tableId) { + return { + fields: [], + empty_fields_notice: { en_US: "Select a table first." }, + } + } + + const schema = await apiClient.getTableSchema(tableId) + return { + fields: schema.columns.map(col => ({ + id: col.id, + display_name: { en_US: col.name }, + type: col.dataType, + required: col.required, + ui: { + hint: col.description ? { en_US: col.description } : null, + }, + })), + } + }, + }, +} +``` + +## `depends_on` Cascading + +> [!IMPORTANT] +> When the available fields depend on another parameter, always declare +> `depends_on` on the mapper. This makes the UI re-fetch the field schema and +> reset the mapping when the referenced parameter changes. +> +> In the schema, `depends_on` is only supported on `resource_locator` and +> `resource_mapper`. The upstream parameters it references should be `string`, +> `number` / `integer`, `boolean`, or `resource_locator` properties. + +```typescript +const fieldsParam: PropertyResourceMapper = { + name: "fields", + type: "resource_mapper", + display_name: { en_US: "Fields" }, + required: true, + depends_on: ["workspace", "table"], + mapping_method: "map_record_fields", +} +``` + +Read upstream parameters from `args.parameters` inside the callback to determine +which schema to query. + ## Helper Function -Use `extractResourceMapper()` from the SDK to safely extract the mapped object value. +Use `extractResourceMapper()` from the SDK to safely extract the mapped object +value: + +```typescript +import { extractResourceMapper } from "@choiceopen/atomemo-plugin-sdk-js" + +invoke: async ({ args }) => { + const fieldValues = extractResourceMapper(args.parameters.fields) + if (fieldValues) { + await apiClient.createRecord(fieldValues) + } +} +``` + +This returns `Record | null`. It throws when the value fails +schema validation or when auto mode resolves to a plain string instead of an +object. + +## UI Behavior + +The field renders as a block with a **mapping mode** selector. + +### Manual Mode + +1. The UI calls `resource_mapping` on mount and whenever a `depends_on` + parameter changes. +2. Each field renders as a typed input row: + + | Field type | Input component | + | ---------- | --------------- | + | `string` | Text input | + | `number` / `integer` | Number input | + | `boolean` | Toggle switch | + | `object` / `array` | JSON code editor | + +3. Required fields cannot be removed. +4. If a field definition includes `ui.hint`, the hint is shown below that + field's input row. +5. An **Add Field** dropdown lists optional fields that were removed, allowing + the user to restore them. +6. If no fields are available, the `empty_fields_notice` from the callback is + displayed. + +### Auto Mode + +Auto mode renders a JSON code editor with expression input enabled. The user +provides the complete mapping as a JSON object or expression string. diff --git a/src/templates/common/skills-lock.json b/src/templates/common/skills-lock.json index f2c045f..38250ba 100644 --- a/src/templates/common/skills-lock.json +++ b/src/templates/common/skills-lock.json @@ -4,7 +4,7 @@ "atomemo-plugin-development": { "source": "choice-open/skills", "sourceType": "github", - "computedHash": "be0b81a3ba4c52a0f8e96924ae1dae5b191da844d2c6a1ba2967f426226fe6b4" + "computedHash": "b05608caf407d54aea6970130ea2c4baab280fd165af805a75a7ed9983ca9660" } } }