diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index fdd296e..ba87eca 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -6,7 +6,10 @@ |---|---|---|---| | Marketplace catalog and Account Library | Signed-in Renderer intent | bounded Host API → Main Marketplace client/cache → Works Square catalog, Library, resolve, Admission, and download APIs | Main binds requests to the current Account and response generation. Free acquisition changes only Account Library; it does not install, enable a project, assign an Agent Skill, or authorize runtime execution. | | Marketplace device installation | Account Library entry and explicit channel | Main resolve/Admission/download → descriptor/signature/archive/client compatibility verification → immutable Package Store index switch | Stable and explicit Beta remain distinct even when they resolve to the same immutable Release. Failed install/update retains the previous current release; explicit uninstall waits only for protecting worker/account references and completes after the final worker exits. | -| Effective Plugin worker snapshot | Installed trusted package + project selection + Agent assignments + current server policy | effective resolver → Registry/resource loader/Extension Host/tool catalog → parent Pi worker | One frozen snapshot supplies Skills, tools, package roots, and runtime authorization. Disable, account/project switch, logout, Renderer crash, Main shutdown, or worker generation change invalidates future actions without mutating persisted unknown assignments; child workers receive no Plugin projection. | +| Official bundled hosted Plugin acquisition | Account Library entry for Game Resource | server stable bundled Release/Admission + MakeLore resource package | Free acquisition changes Library only. No device download, update, Beta, signature, or device-uninstall action exists for this exact code-owned identity. Historical Hosted Web Search entries are ignored by current clients. | +| Selected-model Web Search | Parent Pi turn with an explicitly supported selected model | `makelore_web_search` core tool → frozen model/provider/credential request with provider-native forced search → ordinary model response/usage | No Marketplace Release, Account Library, Admission, Hosted Web Search client, Plugin Charge, or `agent_browser` fallback participates. Unsupported selected models expose no tool; child workers receive none. | +| Conversation-driven Device Package install | Agent tool inspects npm/Git/absolute local Plugin/loose Skill source | Main preview → distinct later user confirmation → immutable device-package generation → new/idle parent worker resources | Renderer has no install picker. Lifecycle scripts never run. Confirmation explicitly states that Pi extensions execute with desktop-user authority. Active workers retain their frozen generation until the turn settles; child workers remain empty. | +| Effective Plugin worker snapshot | Installed trusted package or acquired official bundled definition + project selection + Agent assignments + current server policy | effective resolver → Registry/resource loader/Extension Host/tool catalog → parent Pi worker | One frozen snapshot supplies Skills, tools, package roots, and runtime authorization. Disable, account/project switch, logout, Renderer crash, Main shutdown, or worker generation change invalidates future actions without mutating persisted unknown assignments; child workers receive no Plugin projection. | | Hosted Game Resource operation | Eligible parent `makelore.game-resource` tool call plus explicit confirmation | frozen Plugin adapter → capability Registry → Main `GameResourceClient` → fixed Works Square game-resource route → provider-neutral receipt/result | Server policy owns pricing, payer, Admission and receipt state. Stable logical operation identity survives response loss/Main restart; `submission_unknown` is not replayed as a fresh request. Result saving uses a bounded project-relative path and the existing project write lease. | | 桌面认证生命周期 | Renderer 登录、刷新与注销请求 | Host API → Main Works Session → Works Square `/api/auth/{login,mobile-login,refresh,logout}` → one-feel auth | Main 加密持有并先持久化轮换 token;客户端不携带 OAuth client secret;连续 7 天未使用才清除会话,终止性 `400`/`401` fail closed | | 用户模块入口策略 | 会话恢复 / 登录 / 刷新 | Electron Main → Works `/api/auth/me` → 四布尔安全投影 → Renderer auth store → 卡片/路由/provider gate | 缺失对象或字段默认 `true`;`design` 映射 `painting`;终止性 `401` 清理 Main/Renderer 会话;全局 `/settings` 不受 Code gate | diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 7f9b1f8..759f453 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -4,11 +4,13 @@ | Path | Responsibility | Owner Notes | |---|---|---| -| `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, compatibility checks, canonical archive validation, and Ed25519 trust | Schema 1 bundled compatibility and schema 2 `skill_only`/declarative `platform_hosted` are parsed fail closed. The production trust store accepts no Marketplace package until the official public key is built in. | +| `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, fixed code-owned resource roots, compatibility checks, canonical archive validation, and Ed25519 trust | Data Service plus exact Game Resource roots are code-owned. Other schema-2 `skill_only`/`platform_hosted` packages remain signed Marketplace artifacts and fail closed without the official public key. Hosted Web Search is not a current Plugin root. | | `electron/coding-plugins/account-plugin-cache.ts`, `marketplace-client.ts`, and `package-store.ts` | Main-owned Account Library cache, authenticated server client, and atomic immutable device installation store | Account acquisition and device installation are separate. Package bytes switch atomically only after descriptor, signature, archive, client-range, and provenance checks; failed updates preserve the prior current release. | -| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | One effective installed-plugin projection and frozen parent logical-thread runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Only trusted, compatible, installed, policy-admitted resources enter a new parent logical thread; child workers remain empty and active threads retain frozen bytes until disposal. | +| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | Effective official Plugin projection, selected-model tools, Device Package resources, and frozen parent logical-thread runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Trusted Marketplace artifacts, exact acquired official bundled definitions, and immutable local Device Package generations enter through distinct authorities. Child workers remain empty; active threads retain frozen resources until settlement/disposal. | +| `electron/coding-runtime/pi/model-tools/**` and `shared/model-tools.ts` | Closed selected-model tool registry and provider-specific Web Search request shaping | The frozen selected model capability controls whether `makelore_web_search` exists. The tool uses that model/provider/credential and normal model billing; no Hosted Plugin adapter, Admission, Plugin Charge, or browser fallback exists. | +| `electron/coding-packages/**`, `electron/api/routes/device-packages.ts`, `shared/device-packages.ts`, and `src/stores/device-packages.ts` | Main-owned conversation install preview/confirmation/commit, immutable local package generations, safe Renderer projection, and parent-worker refresh | Sources are npm, Git, absolute local Plugin directories, or loose `SKILL.md`. Lifecycle scripts are disabled; executable extensions run with desktop-user authority after explicit confirmation. Device packages never join Account Library, Marketplace Package Store, Release, Channel, or Admission state. | | `electron/coding-plugins/adapters/game-resource.ts` and `electron/services/game-resource-client.ts` | Provider-neutral `makelore.game-resource` hosted tool adapter and Main-owned Works Square transport | Tools materialize only from an eligible frozen `platform_hosted` parent snapshot. Metered mutations require explicit confirmation and stable logical operation identity; Renderer/Pi never receive Provider URLs, credentials, balances, raw responses, or Provider job IDs. | -| `electron/api/routes/plugin-marketplace.ts`, `src/stores/plugin-marketplace.ts`, `src/components/plugins/PluginServices.tsx`, `src/lib/plugin-services.ts`, `src/pages/ProjectConfiguration/`, `src/pages/PluginMarketplace/`, `src/pages/MyPlugins/`, and `src/pages/ProjectPlugins/` | Bounded Main routes and one Project Configuration Plugin Services surface for catalog, Library, device, and project actions | Renderer receives no Account token, filesystem path, Admission, package bytes, or signed download URL. Discover/mine/project are URL-backed sheet tabs; former standalone routes only redirect to those tabs. Catalog/Library/device/project generations prevent stale A/B or cross-action results from replacing current state. | +| `electron/api/routes/plugin-marketplace.ts`, `src/stores/plugin-marketplace.ts`, `src/components/plugins/PluginServices.tsx`, `src/lib/plugin-services.ts`, `src/pages/ProjectConfiguration/`, `src/pages/PluginMarketplace/`, `src/pages/MyPlugins/`, `src/pages/ProjectPlugins/`, and `src/stores/device-packages.ts` | Bounded Main routes and one Project Configuration Plugin Services surface for Marketplace catalog, Account Library, official package-device state, local Device Packages, and project actions | Renderer receives no Account token, filesystem path, Admission, package bytes, signed download URL, or install-source picker. Discover/mine/project are URL-backed sheet tabs; former standalone routes only redirect to those tabs. My Plugins separates official Plugins from locally installed Device Packages. Catalog/Library/device/project generations prevent stale A/B or cross-action results from replacing current state. | | `src/components/works/ProjectPublishAction.tsx` | 可发布项目的一键提交、云构建轮询与用户可理解状态 | 只通过 Renderer API 提交非敏感元数据;绑定告警不终止轮询 | | `src/lib/works-square.ts` | Renderer 侧 Works Square Host API 契约与安全错误映射 | 不接触 Token、ZIP、本地绝对路径或自动部署状态 | | `electron/api/routes/works.ts` | Works Host API、Renderer capability 门禁、上游安全投影 | 发布凭据、打包、上传与本地绑定均在 Main 内完成 | diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 5e2bd26..04d072b 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -21,8 +21,10 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Makelore Code Workspace | project/Agent/Conversation schema v2、产品中立 Snapshot/Patch、Composer 与时间线 | Conversation 本地创建且不等待 worker;旧 OpenCode 会话备份后不再继续,Renderer 不导入 Pi RPC/event 类型 | | Coding Host API & Composition | 唯一 `/api/coding/*` composition、202 acceptance/dedupe、SSE、附件/文件/交互/诊断 | Electron Main 拥有 project/Conversation 服务、选中目标、认证和错误脱敏;SSE 公开面只有 Snapshot 与 `patch-batch` | | Pi Conversation Runtime | 一个长驻 Pi `0.84.2` Agent Server 承载每条 active/warm Conversation 的隔离逻辑 Runtime/Session/JSONL channel | 严格 LF JSONL RPC、generation recovery、Snapshot hydration;top-level 逻辑 turn 并发 4、warm idle LRU 8;Server 退出统一使旧 channel 失效并按需单实例重启 | -| Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 放进 argv、catalog 或 Renderer | +| Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization、selected-model tools | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;Works `model_capabilities` 由 Main 严格归一化并作为安全 Provider metadata 持久化。Web Search 仅在精确 capability 存在时随冻结的 selected model/provider/credential 进入 parent tool catalog,并走普通模型计费;不回退 `agent_browser` 或独立 Hosted Provider。服务端 reasoning levels 优先于本地 profile,缺字段则清理 override 并回退;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 或原始响应放进 argv、catalog 或 Renderer | | Pi Extension, Subagents & Lifecycle | 唯一显式 Makelore extension、UI interaction、ephemeral child、write lease 与 background run lease | child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或 confirmation timeout 被停止,replacement/stop 必须可解释并清理所有 ownership | +| Official Hosted Plugins | Acquired code-owned bundled Game Resource package → effective parent snapshot → code-owned Main adapter → fixed Works Square hosted route | 无设备下载、更新、Beta 或签名步骤;Renderer/Package/Pi 不持有 Provider key、model 或 URL。每次计费操作要求显式确认,child 不继承 hosted tool。历史 Hosted Web Search 不再进入当前客户端。 | +| Device Packages | Conversation install tools → Main-owned inspect/preview/confirm/commit → immutable local generation → parent Skill/Pi-extension resources | 支持 npm、Git、绝对本地 Plugin 目录与 loose `SKILL.md`;没有可见安装入口、Account Library、Release、Admission 或 Marketplace Package Store。可执行 extension 拥有桌面用户权限且生命周期脚本禁用。新/idle parent 自动刷新,active parent 在 turn settled 后刷新,child 始终为空。 | | AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | Living Form 是服务端 Current Specification 的投影;Renderer 只持有草稿和已接受投影 | | AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer;稳定 command/operation ID 支持 unknown-result 重放,revision conflict 刷新权威状态 | | AI Design Gateway Routing | Main-owned Works Square V2 adapter 与 Direction event stream | Main 持有 Works Token、stream ticket、WebSocket、重试分类和错误脱敏;事件顺序与 Task progress 不构成 Specification 真值 | @@ -56,6 +58,11 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 - Renderer 只消费共享的产品中立 project/Conversation/Snapshot/Patch DTO,并经 `src/lib/host-api.ts` 或 `src/lib/api-client.ts` 访问 Main。Pi RPC、event、Provider credential 和本地 runtime 路径保持 Main-private。 - accepted/uncertain Coding mutation 不得自动重放;RPC confirmation timeout 后仍保留 target run permit、process ownership 和 background lease,直到迟到 success/failure/exit/abort 权威收敛。其他 Conversation 必须继续可用。 - selected Provider credential 只投影到目标父逻辑线程的内存 credential store 或目标 child 进程;跨账号模型变化必须重建目标逻辑线程。确定性 Works user-context 缺失是 Provider-auth failure:失效缓存 credential、fail fast、固定脱敏提示,不得归类为 Pi crash。 +- Works 下发的 per-model reasoning capability 是可选 Main-owned metadata,不是 + Renderer 或 one-api 的权威。存在时只接受受支持的安全形状并覆盖目标模型的 + 本地 effort map;缺失时移除旧 override 并使用已验证本地 profile。`off` 在 Pi + wire 上表示 `thinking.type=disabled` 且不发送 `reasoning_effort`,启用档位保持 + provider 原生值,当前 DeepSeek 产品投影为 `off`/`low`/`high`/`max`。 - Guided Hotspot Binding is implemented behind a Main-owned capability that is true by default; exact `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` disables it, while capability-read failure falls back to direct six-digit Binding. The guided state is process-local, opener failures expose only the same fixed address for manual copy, and Binding conflicts refresh the safe account overview. - Robot hotspot scanning and connection are local Main operations that return before Works credentials/upstream access. Renderer may submit only an opaque candidate ID from the latest bounded scan; Main alone filters open printable `Xiaozhi-*` SSIDs, performs platform association, and verifies the exact current SSID. - Hotspot discovery and connection do not authenticate a Robot. BSSID, interface/profile details, native diagnostics, location data, and Wi-Fi credentials never cross the Main boundary; permission or platform failure keeps the system-settings/manual path available. @@ -87,8 +94,8 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 ## Related Decisions -- 当前长期边界记录于 README、ADR-002 至 ADR-007(ADR-001 已由 ADR-007 supersede)及各 Integration Gate;后续如改变唯一入口、凭据所有权、构建执行边界、Living Form/Specification 状态归属、Pi 单 runtime/worker/lease 边界、Learning/Marketplace 分发边界、Robot 配网边界或重新引入客户端部署协调器,应新增 ADR。 +- 当前长期边界记录于 README、ADR-002 至 ADR-007(ADR-001 已由 ADR-007 supersede)及各 Integration Gate;后续如改变唯一入口、凭据所有权、构建执行边界、Living Form/Specification 状态归属、Pi 单 runtime/worker/lease 边界、Learning/Marketplace/Web Search 分发边界、Robot 配网边界或重新引入客户端部署协调器,应新增 ADR。 ## Last Updated -2026-08-31 +2026-09-01 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 83db085..94cad87 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -35,6 +35,198 @@ This file is the integrated default-branch snapshot. Feature tasks record progre and collapsed process previews remain anchored to their first displayable line. Historical source `b1f51be` remains explicitly excluded because ADR-005 continues to retire the OpenMAIC Learning Player packaging chain. +- Youth-facing AI Design client source + `0fd32a2d49045a8f9e7f2e19ba6477f48f93e30c` is integrated over Model Tools + frontier `7552cf59526449c29d663a769c0fb62d84a1a759` through merge + `16157388afc2f6103aaee0bfd3c4e4e80c8fec48` with startup correction + `c5447ae90c434c06dbafa724b229d8459cb829d0` by task + `20260902-design-youth-client-integration-7a6d3c92`. Canvas now defaults to + conversation plus one compact “我的创作” card for creators aged 8-16, with + contextual “精细调整” instead of an always-visible professional field matrix. + The card remains a pure projection of the canonical V2 Current Specification; + direct choices use typed commands, Quote blockers follow Specification revision, + terminal blocked Quotes settle pending operations, and code-based Chinese copy + prevents raw compiler/Provider text from reaching the youth UI. Video editing + preserves stable canonical Shot identities and all hidden production metadata for + up to 24 Shots. No database, DTO/API, pricing, Provider route, or compatibility + path changed. Integration verification passed 40 focused youth tests, 5 Device + Package tests, typecheck, scoped lint, production build, and 2 Electron E2E flows. + The Electron run also exposed and closed an existing current-frontier startup crash: + the ESM-only Pi package manager is now loaded only when remote Device Package + installation needs it. No package, deployment, publication, push, or paid request + is claimed. +- Model Tools and Device Packages client cutover is implemented by task + `20260902-model-tools-client-integration-5d8b2f73`. Web Search is now a parent-only + core tool of the frozen selected model/provider/credential; it no longer depends on + Marketplace acquisition, Release, Admission, the hosted client, or Plugin Token Point + receipts, and it never falls back to `agent_browser`. Electron Main also owns a + conversation-only Device Package manager for npm, Git, local Plugin directories, and + loose `SKILL.md` sources. Preview and a distinct confirmation turn precede immutable + local commit; lifecycle scripts stay disabled; new and idle parent workers refresh + automatically while active workers switch after settlement; child workers remain + empty. My Plugins now separates Official Plugins from Device Installed packages and + exposes no visible install picker. Packaged Skill/Pi-extension installation and real + selected-model search remain external acceptance gates; no paid Provider request, + production install, publish, push, or PR is claimed. +- Windows titlebar Logo-overlap correction source + `99a210e244731d1cdc923e9dd4adf6e09e64c2a4` from task + `20260901-windows-titlebar-logo-overlap-5100e298` is integrated onto local + `main` through product commit `e800d42595484389432fd08b11c2202074a7009e` + by task `20260902-windows-titlebar-integration-afc9e259`. The shared + `ProductTitleBar` now uses one 148 px Windows caption-control region (three + 44 px buttons plus 8 px padding on each side) both to size the control wrapper + and to inset the Makelore Logo. Canvas and Coding Electron geometry checks + confirm that the Logo ends before the minimize button; macOS keeps its zero + inset and native traffic-light behavior. Focused 11-test, full 1,788-test plus + pressure, typecheck, scoped/full lint, Vite build, and two Electron E2E checks + passed. No package, deployment, publication, backend contract, or unrelated + product layout changed. +- Web Search unavailable-Release diagnostic correction source + `2e093bd22c0a3c46cc08b8eede8c1bc02afd690b` from task + `20260901-web-search-runtime-stale-r2-6f4a2d91` is integrated on local `main` + through product commit `93e3143` by task + `20260902-web-search-client-integration-a4c8e291`. When the server resolve API + explicitly returns `action='unavailable'` for `plugin_release_not_ready` or + `plugin_client_incompatible`, Electron Main now preserves the existing typed + `plugin_release_unavailable` result instead of mislabeling it as the unrelated + frozen-worker `plugin_runtime_stale` error. Exact Release, Account/project, policy, + Admission, Provider, confirmation, billing, and child-isolation gates are unchanged. + The current installed client still predates this diagnostic correction, and actual + usability still requires Works Square to deploy/apply the missing official bundled + Release and compatibility migrations; no package, restart, deployment, publication, + or paid Provider request occurred. +- Web Search frozen-worker stale-Library correction source + `3620cdc277fa0a99757c57a549008f9ecd380558` from task + `20260901-web-search-stale-resources-8b4e2c71` is integrated onto local `main` + through product commit `40e1912` by task + `20260901-web-search-stale-integration-9c5f3d82`. Live client evidence showed an + eligible parent worker with the official Web Search Skill, tool, Release and policy + failing `409 plugin_runtime_stale` because invocation re-ran full materialization and + treated one transient Account Library refresh as permanent worker invalidation. New + workers still require a current Library; a previously frozen worker may now use the + last verified Library projection only long enough to obtain the server-owned exact + Release Admission. Account/session change, project disable, acquisition removal, + runtime suspension, Release/version change, policy/billing gates and child isolation + remain fail closed. Focused 19, adjacent 55, full 1,786 plus pressure, typecheck and + scoped lint passed. The currently running installed binary predates this source fix; + no restart, package, deploy, publication or paid Provider call was performed. +- Web Search Agent-assignment catalog fix source + `612794463de1b14fd748202a00115c6f93c7346b` from task + `20260901-web-search-tool-routing-fix-6f9d3b82` is integrated onto local `main` + through product commit `adc28db7855f8b1770f1c1d46af77b7cc88cfa1a` by task + `20260901-web-search-tool-routing-integration-7a4e1c93`. The unscoped partner + Skill picker now lists every project-enabled Plugin Skill before first assignment + and does not invoke runtime Admission resolution; Agent-scoped/runtime projections + still use only the resolver-approved effective subset. This closes the confirmed + circular state where an enabled Web Search Skill was hidden until already assigned, + leaving the actual Pi worker with only `agent_browser`. Focused 45-test, full + 1,785-test plus pressure, typecheck, and scoped-lint verification passed. Existing + workers remain frozen; the affected local Agent assignment was corrected separately + and takes effect on a new Conversation. +- Official bundled Plugin client cutover is integrated on local `main` through product + commit `1530ac774091c7083dbff19e0fbd69e929ac8718` by task + `20260901-official-bundled-plugins-client-integration-b8d5f3a2`. Game Resource and + Web Search now ship as exact schema-2 resource packages with MakeLore; their acquired + Library entries resolve through the server Admission path without Package Store + download, update, Beta, signature, or device-uninstall state. Project enablement, + Agent assignment, parent-only materialization, Provider policy, confirmation, and + Token Point billing remain enforced. The verified Windows installer embeds source + `b1657300f29f9744551f31029eb192ee82f01b92`, is 208,297,578 bytes, and has SHA-256 + `449288AF079E030F3F700F0FF1701971F7AB4806E0366CE989F4E8F2FE33ACA3`. +- Conversation abort reconciliation source + `d2ef37bc4d7d3609cec0a55c4ae8ffb2734696d5` from task + `20260901-conversation-abort-stall-6f4c2a91` is merged into local `main` as + `85900717906713d8343c2087eade904aead8f111` through verified candidate + `599d1847f73a590690cbac2ee1c5bf36e8cd83f1` by task + `20260901-merge-abort-main-a83d4c71`. + Both visible abort actions now call the Main-owned abort route and then silently + reload only the target Conversation's authoritative Snapshot. This closes the + installed 1.2.1 case where Pi had already recorded bash results and a final + assistant `stop`, but Renderer missed the terminal SSE patch and a later valid + abort no-op left the UI showing an executing turn. Pi `0.84.2`, Host API, + replay, recovery, and background-lifecycle contracts are unchanged. Focused + 35-test, 1,780-test full-suite plus pressure, typecheck, lint, production-build, + and Electron E2E verification passed. The existing 1.2.1 artifact predates this + fix; a newly versioned package and installed-client acceptance remain pending. +- Agent Server background-sleep race fix source + `12d7588b3ebd4d192c2e14ae285d4f6ddebeeb42` from task + `20260901-agent-received-stall-8b6d4c21` is merged into local `main` as + `c2137c9f3f2e05441064cc90bfeed115363ac04e` by task + `20260901-integrate-agent-stall-4f2c8a91`. Background sleep now rechecks + active Coding work after asynchronous worker cleanup before stopping the shared + Agent Server, and a server start racing an in-flight stop waits for that stop and + creates one fresh process. This closes the confirmed lifecycle windows that can + leave a locally accepted prompt optimistic with no live Agent Server or Pi session + write; accepted/uncertain prompts are still never auto-replayed. Source tests and + a real Electron-Node Agent Server race test pass. The currently installed binary + predates this integration; a rebuilt/reinstalled Windows package and repetition of + the reported interaction remain pending release evidence. +- Works-provisioned model-reasoning capability source + `ae7936174208a1d13cdfd260d5c6f2b70b450b60` from task + `20260901-server-model-capabilities-9e31b6c4` is merged into local `main` as + `6a8ebe1b671ca24150c2226b9111b9d07174e37b` by task + `20260901-integrate-model-capabilities-9b3e7c1a`, paired with Works Square source + `9e1b6886b360175f1ca1596fb07f71e3bf86c894`. Electron Main now strictly + normalizes and persists the optional safe `model_capabilities` metadata, removes a + stale override when the field is absent, and includes it in Provider runtime-shape + invalidation. Server levels override the verified local reasoning map; old servers + and direct Providers retain the local fallback. DeepSeek exposes + `off`/`low`/`high`/`max`: Pi disables thinking without `reasoning_effort` for + `off`, and sends the exact enabled effort otherwise. Native `max` reaches project + persistence, Snapshot/Patch, Host API, runtime, and the composer label `最高`. + Pi remains `0.84.2`; no one-api, dependency, package, deployment, or real Provider + acceptance is claimed. +- Marketplace weak-ETag interoperability fix source + `b3cfe7e1ceb7da65ccc99214db09102b4fc1cace` from task + `20260901-plugin-catalog-load-client-7d4a8c21` is promoted to local `main` by + task `20260901-plugin-catalog-main-integration-8e5c2a91` through product commit + `38f2358`. The Main-owned Marketplace client now accepts both strong and valid + weak composite catalog ETags, preserves the exact received validator for the next + `If-None-Match`, and retains catalog-generation and Token Point pricing identity + checks. This closes the installed-client failure where the production gateway's + compressed `W/"plugins-..."` response was incorrectly surfaced as + `plugin_backend_unavailable`; it does not add a local fallback catalog or change + server, Provider, trust, pricing, or billing authority. The rebuilt Windows 1.2.0 + installer embeds verification head `6083de6aee8942a78191ed18a00bfd9f4ba0902d`, + is 294,864,542 bytes with SHA-256 + `1301AE189BCBD44AA0E373982981E80B324EC45CDDF8F30415C2810F68319C06`, + and was installed over the prior 1.2.0 at the existing user-selected location. + The signed-in installed client then loaded the deployed generation-2 catalog and + rendered exactly Data Service, Game Resource, and Web Search without the unavailable + catalog error. At that checkpoint Game Resource and Web Search still showed no stable + Release; the official bundled cutover recorded above supersedes that delivery model. +- Packaged Pi Agent Server resolver fix source `7df245af5a04f62be48980831ff41987ba686009` + from task `20260901-local-runtime-unavailable-8b42c7f1` is promoted to local + `main` by task `20260901-promote-local-runtime-main-a7c4e291` through verified + integration candidate `42ea83c0cbad52432eca99e4161e0360bef3e219` and source merge + `96402551f46d875ba3db0a2f625397aba2f332fb`. Electron Main now starts only the + shared Agent Server with Node's `import.meta.resolve` parent-URL capability enabled, + so the unchanged server script resolves `@earendil-works/pi-ai` from the explicit + packaged `pi-runtime` root instead of the sibling `resources/resources` directory. + A production-shaped sibling-layout test reproduces the installed failure before the + fix and initializes successfully through Electron Node after it; Pi remains pinned at + `0.84.2`, and no bundle layout, Provider, Renderer, or recovery contract changed. + The currently installed application predates this source integration; a rebuilt and + verified Windows artifact plus first-Conversation acceptance remain pending. +- Native Web Search client tickets MLW-01 through MLW-03 are integrated by source + coordinator `20260831-web-search-client-integration-7d2f5b94` and promoted to local + `main` by task `20260901-web-search-client-main-merge-5a9d3b82` from reviewed + coordinator closure `ae81949a49d7df3be4859e6c111235a991a504e4` through product commit + `49de82c4860fd0b377070279b6411237dc6b9564`; the final fixed-range Standards and + Spec reviews passed at `4de3feefe451dc34dc46b323e2ea5e0b4e4840e8` with zero + actionable findings, against frozen Works Square DTO frontier + `a49c696ebc4213e3d62ece780961efbe17576f8e`. MakeLore now reuses the shared + hosted Admission resolver, calls the single fixed Web Search typed route from + Electron Main, materializes the signed Marketplace Skill/tool only in an eligible + frozen parent Pi snapshot, and renders the closed billing receipt without parsing + Web Search payloads in Renderer. The packaged Windows app proves that the route + and receipt parser are main-reachable while OpenAI Provider authority is absent. + The exact reviewed Windows package passed artifact verification; installer SHA-256 + is `2492F88BB6F813834ECD24B392EB8337220E131E746547851393E4885C4B5BEF` and + `app.asar` SHA-256 is `27EFABBB741F0A62CB58452801DD1D8893B8E5131EA1D30F74D610DEAD3F7A1D`. + Real PostgreSQL and paid OpenAI acceptance remain external HOLDs; production + activation still requires the official Ed25519 key, OpenAI key/model, price, and + privacy copy. No deployment, publication, push, or PR occurred. - Human-authorized takeover task `20260831-promote-main-merge-5e9c7a31` completed the already-started local `main` merge as `03a9e866d4366e0a1cb416e26b424fe981071310`, recorded the transfer in @@ -240,7 +432,7 @@ This file is the integrated default-branch snapshot. Feature tasks record progre 客户端面向非专业用户提供“创建小游戏或小程序 → 项目配置中一键提交 → Main 本地 npm/Vite 构建 → Electron 双视口预检最终产物 → 上传 source+built 双归档与 contract → 运营审核”的唯一创建者链路。Main 对安全源码快照运行安装包内固定 npm 11.6.2 的 `npm ci --ignore-scripts`,再显式调用项目 `package-lock.json` 锁定的 Vite;Vite config/plugins 以当前桌面用户权限执行,因此只适用于用户信任的本地项目,不是 sandbox。预检由 Main 以临时 loopback origin 和 Electron WebContents/CDP 检查与最终 `built_archive` 相同的内存文件字节,覆盖桌面/移动视口、运行错误、白屏和外域访问;不使用 Playwright。该检查仍可由非官方客户端绕过,不产生可信 receipt,也不复刻生产 opaque-origin。服务端不再替客户端运行项目 Vite,而是把源码、构建归档和 contract 视为不可信输入,逐字节重算与校验并固化不可变 Release;人工审核仍不可绕过。自定义和缺少类型字段的旧项目不提供该入口。已发布作品优先使用安全投影后的 `play_url`,`runtime_url` 仅保留一个客户端版本的兼容回退。 -AI Design Canvas 现在是持续可见的双栏 Living Form 工作区。一个 Workspace 公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specification;conversation timeline 只记录交互历史。Chat、direct edits、decision responses、proposal acceptance、locks、Asset binding 与 restore 都通过 `design.input.apply` 进入同一服务端 reducer,Renderer drafts 在 accepted 前保持本地。Main 持有 Works Token、stream ticket、WebSocket、request deadline、stable command/operation IDs 与错误脱敏;unknown result 只能复用原 identity,结构化业务错误不得重放。Generation 由服务端对 exact Specification revision 编译 immutable Quote,客户端只展示 public output plan、warnings、expiry 与 Token Points,并以 Quote ID 调用 `design.generation.confirm`;Provider Prompt、model、route、storage 和 billing atoms 不进入 Renderer。Task/Asset events 独立收敛 Workspace resources,不改写 Living Form。Development 与 packaged builds 均使用 Works Square V2,V1 DTO、local semantic adapter、mutable Quote PATCH 与 editable provider Prompt 已移除。 +AI Design Canvas 现在默认以对话和一张持续可见的“我的创作”卡服务 8-16 岁创作者;专业字段矩阵收进按需打开的“精细调整”,移动端保持对话优先并只挂载一个卡片/底部面板。Creation Card、精细调整、Quote、Task 与结果提示都只投影权威状态,已知问题按 code 转成通俗中文,未知服务端或 Provider 文本不会直接显示。一个 Workspace 仍只公开一个 current Direction、一个 persistent Agent Session 和一个 Current Specification;conversation timeline 只记录交互历史。Chat、direct edits、decision responses、proposal acceptance、locks、Asset binding 与 restore 都通过 `design.input.apply` 进入同一服务端 reducer,Renderer drafts 在 accepted 前保持本地。Main 持有 Works Token、stream ticket、WebSocket、request deadline、stable command/operation IDs 与错误脱敏;unknown result 只能复用原 identity,结构化业务错误不得重放。Generation 由服务端对 exact Specification revision 编译 immutable Quote,客户端只展示 public output plan、warnings、expiry 与 Token Points,并以 Quote ID 调用 `design.generation.confirm`;Provider Prompt、model、route、storage 和 billing atoms 不进入 Renderer。Task/Asset events 独立收敛 Workspace resources,不改写 Living Form。Development 与 packaged builds 均使用 Works Square V2,V1 DTO、local semantic adapter、mutable Quote PATCH 与 editable provider Prompt 已移除。 Canvas 侧栏提供“获取灵感”进入 Prompt Museum。列表、筛选、分页、详情、作者/来源/许可证和图片地址全部由服务端经 Main-owned Host API 提供,客户端不打包静态数据集;服务端相对媒体只允许固定 `/api/image-prompt-museum/{entry}/media/{thumbnail|number}` 形状,并由 Main 注入 Works Bearer、执行一次 401 刷新、可信 raster MIME 与 10 MiB 上限后转为 Renderer data URL;credential-free HTTPS CDN 图片保持直连。图片失败只显示卡片内占位,不阻断卡片或详情;缺少来源 URL 时显示纯文本。“使用此 Prompt”只把原文带回当前 Canvas 输入框,不自动发送。该模块不是投稿、点赞、评论或排行榜社区。客户端契约已就绪,但不据此宣称 Works Square 内容后台和生产数据已经部署。`pnpm run dev` 现在默认使用云端 Canvas 适配器,本地适配器只能通过显式开发命令启用;产品 UI 只保留中文。 diff --git a/.project-docs/30-worklog/tasks/20260831-makelore-web-search-client-inspection-6b4d2e81.md b/.project-docs/30-worklog/tasks/20260831-makelore-web-search-client-inspection-6b4d2e81.md new file mode 100644 index 0000000..124d468 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260831-makelore-web-search-client-inspection-6b4d2e81.md @@ -0,0 +1,70 @@ +# Task: Inspect MakeLore Web Search Plugin seams + +## Identity + +- Task ID: 20260831-makelore-web-search-client-inspection-6b4d2e81 +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: bf32fecd6013101485434b1977a73beb8b6a1fe3 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Inspect the exact MakeLore Main, Marketplace Package Store, effective plugin resolver, + capability registry, Pi worker, conversation-envelope, and Renderer seams needed by a + future `makelore.web-search` hosted Plugin. +- Record client ownership and acceptance requirements in the server-side task-owned Web + Search design/spec. Do not implement product code in this client root. + +## Intent And Constraints + +- The installed schema-2 package remains declarative; only a code-owned Main adapter may + call the fixed Works Square typed route. Pi and Renderer never receive Provider secrets + or arbitrary endpoints. +- A tool is materialized only for an acquired, installed, compatible, project-enabled, + Agent-assigned, admitted and currently supported Release, parent worker only. Existing + account/project/logout/worker invalidation and stable logical-operation identity apply. +- Preserve the generic Marketplace/My Plugins surfaces and + `makelore-capability.v1` conversation parser. P0 needs no plugin-specific Renderer panel. +- Concurrent Task Gate: PASS. This clean root was claimed at exact base + `bf32fecd6013101485434b1977a73beb8b6a1fe3`; no other active writer owns this semantic + slice and only this task record is changed. +- Planning Gate: PASS after loading AGENTS, project memory, current plugin source and + tests. The project-positioning document is still a template and is therefore recorded + as stale context; AGENTS, current-state, exact source, and tests are the implementation + authority for this inspection. + +## Outcome + +- Completed a read-only client seam inspection and fed exact ownership/acceptance + requirements into the server task's detailed design and implementation Spec. +- Confirmed Web Search can remain a signed schema-2 `platform_hosted` package with one + code-owned Main adapter; no static Pi allowlist, third-party executable, Provider + secret/model, generic invoke route, or Plugin-specific results page is required. +- Identified one necessary generic UI change: the conversation timeline may render the + closed billing union, while `web-search.v1` answer/sources remain tool payload used by + the Agent and preserved by conversation details. +- No client product file was changed. + +## Verification + +- Inspected current Package Store, Marketplace client, effective resolver, capability + registry, Game Resource hosted adapter, Pi worker materialization, conversation parser, + timeline, composition and their focused tests at exact base + `bf32fecd6013101485434b1977a73beb8b6a1fe3`. +- Confirmed `CapabilityBillingReceiptV1` currently has no Main transport-ambiguity + variant and the generic timeline currently shows only operation/outcome; both changes + are explicitly owned by MLW-02/MLW-03 rather than silently assumed. +- Final `check_project_docs` and task-aware `check_doc_drift` pass. + +## Follow-ups + +- Start client implementation only after the server WSW-03 typed DTO is frozen, then + follow MLW-01 -> MLW-02 -> MLW-03 and fixed-range review from the current integrated + client frontier. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-integrate-agent-stall-4f2c8a91.md b/.project-docs/30-worklog/tasks/20260901-integrate-agent-stall-4f2c8a91.md new file mode 100644 index 0000000..b4c2018 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-integrate-agent-stall-4f2c8a91.md @@ -0,0 +1,58 @@ +# Task: Integrate Agent Server sleep restart fix + +## Identity + +- Task ID: 20260901-integrate-agent-stall-4f2c8a91 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 738eda14300b18beff826d87e0e043317ec5bb7d +- Owner: codex +- Status: Completed + +## Scope + +- Merge exact feature commit `12d7588c` into local `main`. +- Preserve the already-integrated model-reasoning capability behavior. +- Record the integration in canonical current state without changing Pi, Provider, packaging, or replay contracts. + +## Intent And Constraints + +- Pi `0.84.2` remains the sole Coding runtime and one Main-owned Agent Server remains the parent topology. +- Background sleep must not stop work that begins during cleanup, and start racing stop must create one fresh server after stop settles. +- Accepted and uncertain prompts must never be replayed automatically. +- Stop on any merge conflict or semantic conflict; do not choose by merge order. +- Do not package, install, deploy, push, or change dependencies in this integration task. + +## Plan + +1. Verify the exact source commit and a conflict-free merge tree against current `main`. +2. Merge the source branch with an explicit merge commit. +3. Record the integrated behavior and remaining installed-package gate in canonical current state. +4. Run focused lifecycle tests, typecheck, lint, production build, and documentation drift checks. +5. Commit integration memory, complete the task, and release ownership from a clean worktree. + +## Outcome + +- Verified source commit `12d7588b3ebd4d192c2e14ae285d4f6ddebeeb42` and conflict-free merge tree `63750df00cda0da00304725b6f0a8c17dd5e09b0` against current `main`. +- Merged the exact source into local `main` as `c2137c9f3f2e05441064cc90bfeed115363ac04e` without conflict. +- Preserved the already-integrated model-reasoning capability changes and the Pi `0.84.2` single-Agent-Server architecture. +- Recorded the background-sleep recheck and serialized stop/start behavior in canonical current state. +- No package, installation, deployment, push, dependency, Provider, or replay-contract change was performed. + +## Verification + +- `git merge-tree --write-tree HEAD 12d7588` returned conflict-free tree `63750df00cda0da00304725b6f0a8c17dd5e09b0` before merge. +- Merged-main focused lifecycle run: 4 files / 9 tests passed. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: zero errors and the repository's existing five warnings. +- `pnpm test`: 216 files passed; 1,780 tests passed and 2 skipped. +- `pnpm run build:vite`: Renderer, Electron Main, Preload, and utility production builds passed with existing build warnings only. + +## Follow-ups + +- Build, verify, and reinstall a Windows package from integrated `main`, then repeat the reported prompt-after-idle interaction. The currently installed application still contains the old lifecycle code. + +## Promotion Candidates + +- Resolved: recorded the exact source and merge commits, lifecycle behavior, no-replay invariant, and remaining installed-package gate in `.project-docs/30-worklog/current-state.md`. No ADR or architecture rewrite was needed because the fix enforces ADR-006. diff --git a/.project-docs/30-worklog/tasks/20260901-integrate-model-capabilities-9b3e7c1a.md b/.project-docs/30-worklog/tasks/20260901-integrate-model-capabilities-9b3e7c1a.md new file mode 100644 index 0000000..a5e8ca6 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-integrate-model-capabilities-9b3e7c1a.md @@ -0,0 +1,84 @@ +# Task: Integrate model reasoning capabilities + +## Identity + +- Task ID: 20260901-integrate-model-capabilities-9b3e7c1a +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: a30053c30e48a0da8639f13e117168f5afd7532e +- Owner: codex +- Status: Completed + +## Scope + +- Merge exact source commit `ae7936174208a1d13cdfd260d5c6f2b70b450b60` + into local `main` without changing the reviewed product behavior. +- Promote the paired Works-to-Main capability normalization and Pi precedence + contract into canonical architecture/current state memory. +- Do not package, deploy, push, change Pi/one-api dependencies, or add a second + runtime or compatibility layer. + +## Intent And Constraints + +- Electron Main remains the sole owner of Works model config, Provider metadata, + credentials, and Pi wire values; Renderer receives only product-neutral levels. +- Server capability metadata overrides the verified local reasoning map when present; + an older server falls back locally, and persisted user choice remains intact. +- Integrate only the reviewed source commit. Any merge conflict or semantic conflict + with current `main` stops integration for explicit reconciliation. +- Pair the client merge with Works Square source + `9e1b6886b360175f1ca1596fb07f71e3bf86c894`; no one-api change is required. + +## Plan + +1. Verify source identity, worktree cleanliness, and a conflict-free merge tree. +2. Merge the exact source branch into `main` with a merge commit. +3. Promote the Main-owned capability normalization/precedence boundary into canonical + project memory. +4. Run focused serializer/UI/contract tests, typecheck, lint, Vite build, diff checks, + and documentation drift checks. +5. Commit the integration record, complete the task, and release ownership only from + a clean `main` worktree. + +## Outcome + +- Merged exact reviewed source `ae7936174208a1d13cdfd260d5c6f2b70b450b60` + into local `main` as merge commit + `6a8ebe1b671ca24150c2226b9111b9d07174e37b`; the merge was conflict-free. +- Promoted the Main-owned Works capability normalization/persistence/precedence and + Pi wire boundary into canonical current-state and architecture memory. +- Confirmed paired Works Square source + `9e1b6886b360175f1ca1596fb07f71e3bf86c894` is merged into local server `master` as + `ef73b9c97997942996ddd3ed3cd3bf4e29120bd0`. +- Kept the reviewed scope: Pi remains `0.84.2`; no one-api, dependency, package, + deployment, push, second runtime, or compatibility layer was added. + +## Verification + +- `git merge-tree --write-tree` returned conflict-free tree + `94c573f77dd11931750a255d537c800c6f5d09c9` before merge. +- Focused merged-tree Vitest run: 8 files / 111 tests passed, covering Works + capability import and cleanup, model/profile precedence, project/Snapshot/route/UI + `max` propagation, Pi runtime behavior, and actual off/low/high/max serializer output. +- `pnpm run typecheck`: passed. +- `pnpm run lint:check`: passed with zero errors and the repository's existing five + warnings. +- `pnpm run build:vite`: passed for Renderer, Electron Main, Preload, and utility + output; existing Browserslist, dynamic-import, and chunk-size warnings remain. +- `git diff --check`: passed. +- Paired server merged-tree verification passed 11 focused tests and focused Ruff. + +## Follow-ups + +- Release the paired Works Square API and Makelore client when release owners are + ready. Deployment order remains tolerant: an older server uses the local DeepSeek + profile and an older client ignores the optional server field. Real Provider and + final installed-package acceptance remain separate release evidence. + +## Promotion Candidates + +- Resolved: promoted the safe Works-to-Provider metadata contract, missing-field + cleanup/fallback, server-over-local precedence, and Pi `off`/enabled wire behavior + into `.project-docs/20-architecture/system-overview.md`; recorded the integration + in `.project-docs/30-worklog/current-state.md`. diff --git a/.project-docs/30-worklog/tasks/20260901-merge-abort-main-a83d4c71.md b/.project-docs/30-worklog/tasks/20260901-merge-abort-main-a83d4c71.md new file mode 100644 index 0000000..c587aed --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-merge-abort-main-a83d4c71.md @@ -0,0 +1,91 @@ +# Task: Merge conversation abort reconciliation into main + +## Identity + +- Task ID: 20260901-merge-abort-main-a83d4c71 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: ebc46c679da3907fd337e51b54c2da9f72c02755 +- Owner: codex-root +- Status: Completed + +## Scope + +- Merge exact verified candidate `599d184` into local `main` after the + human-authorized closure of packaging task `20260901-package-121-7a3c`. +- Preserve the candidate's product implementation and canonical current-state + reconciliation while retaining the packaging task's committed record. +- Verify the merged product tree, exact ancestry, focused behavior, documentation + boundaries, and clean task state. Do not package, install, publish, deploy, or push. + +## Intent And Constraints + +- Preserve ADR-006: Pi `0.84.2` remains the only production Coding runtime; + Electron Main remains the Host API authority; accepted or uncertain work is never + replayed; recovery remains target-only Snapshot/Patch convergence. +- The existing 1.2.1 installer was built from pre-fix source and must not be + overwritten or represented as containing this change. +- The user explicitly authorized takeover of the known packaging task. That task was + closed in commit `ebc46c6` and released cleanly before this Integration Gate began. +- Concurrent and Planning Gates passed on exact base `ebc46c6`: the active record, + candidate/source outcomes, accepted decision, canonical memory, and all 210 peer + records were read. No semantic conflict or competing integration owner exists. + +## Plan + +1. Verify the candidate ancestry and conflict-free merge tree against current `main`. +2. Merge candidate `599d184` with an explicit integration commit, preserving both + the packaging closure and verified abort-reconciliation history. +3. Run the focused abort UI regression plus typecheck and production build, then + verify exact candidate product-tree parity and repository documentation gates. +4. Record the actual merge and verification outcome, commit the integration ledger, + complete the task, and release `main` from a clean state. + +## Outcome + +- Verified a conflict-free merge tree + `06a1e65143c10c67c4eda1b9ee277e13210bf01f` between current `main` and + candidate `599d184`; their common base was `143aaec`, with one `main`-only and + two candidate-only commits. +- Merged the exact candidate into local `main` as + `85900717906713d8343c2087eade904aead8f111`, preserving both packaging closure + `ebc46c6` and the feature/integration history `d2ef37b` / `599d184`. +- Omitted the two source-owned task-record copies from the final `main` file tree + after the task-aware drift gate correctly rejected them. Their unchanged records + remain reachable in source/candidate commits and merge history; product code, + tests, and canonical current state remain integrated. +- Canonical current state now records the actual local-main merge rather than a + prepared promotion. No ADR, architecture, domain, README, dependency, package, + installed data, deployment, publication, or remote branch was changed. + +## Verification + +- Post-merge product tree exactly matched verified candidate `599d184`; no non-doc + path differs, and the candidate is an ancestor of `main`. +- Focused post-merge regression passed: 5 files / 35 tests. +- `pnpm run typecheck` passed. +- `pnpm run lint:check` passed with 0 errors and the same 5 existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `pnpm run build:vite` passed for Renderer, Electron Main, Preload, and release + utility bundles with existing build warnings only. +- Post-merge Electron E2E passed: targeted `PI feature UI` scenario 1/1, including + the missed-terminal-SSE abort reconciliation and both controls unlocking. +- Source full-suite evidence remains applicable because the merged product tree is + byte-identical to the candidate: 215 files / 1,780 tests with 2 skips, plus the + isolated pressure test 1/1. +- Task-aware documentation drift, project-document structure, merge ancestry, + product-tree parity, whitespace, and clean-status checks are rerun before release. + +## Follow-ups + +- Increment the application version, build and verify a new Windows installer, + install it, and repeat the reported interaction before claiming installed-client + acceptance. The existing 1.2.1 installer remains valid historical evidence but + does not contain this fix. + +## Promotion Candidates + +- Resolved: `.project-docs/30-worklog/current-state.md` records the exact source, + verified candidate, local-main merge, unchanged contracts, and pending release + gate. No further canonical promotion is proposed. diff --git a/.project-docs/30-worklog/tasks/20260901-official-bundled-plugins-client-integration-b8d5f3a2.md b/.project-docs/30-worklog/tasks/20260901-official-bundled-plugins-client-integration-b8d5f3a2.md new file mode 100644 index 0000000..fe5dcf1 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-official-bundled-plugins-client-integration-b8d5f3a2.md @@ -0,0 +1,47 @@ +# Task: Integrate official bundled Plugins into MakeLore main + +## Identity + +- Task ID: 20260901-official-bundled-plugins-client-integration-b8d5f3a2 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 52e2a97a12ffaf838c3218795ff33e7fb91940cf +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate product commit `b1657300f29f9744551f31029eb192ee82f01b92` onto client `main`. +- Record bundled optional Plugin delivery in canonical project memory and verify the exact integrated tree. +- Preserve the unrelated untracked `20260901-package-122-c5e8` task record byte-for-byte. + +## Intent And Constraints + +- Preserve account acquisition/removal, project enablement, Agent assignment, hosted Admission, and Token Point billing. +- Keep third-party signed Artifact installation unchanged and keep Provider secrets out of the client. +- Do not publish, deploy, or overwrite the existing user-owned task record. + +## Outcome + +- Product source `b1657300f29f9744551f31029eb192ee82f01b92` was cherry-picked onto local `main` as `1530ac774091c7083dbff19e0fbd69e929ac8718`. +- Game Resource and Web Search package manifests, Skills, and tool catalogs now ship under fixed MakeLore resource roots and resolve without Package Store installation. +- My Plugins and Project Plugins project the official bundled model: Account remove/project enable/Agent assignment remain, while download/update/Beta/device-delete and “not downloaded” states are absent. +- The foreign feature task record was removed only from the integrated branch; its isolated source branch remains intact. The unrelated untracked `20260901-package-122-c5e8.md` file was preserved. +- Canonical architecture, data-flow, domain rules, and current-state memory now describe the bundled optional Plugin boundary. + +## Verification + +- Exact integrated focused run: 61 passed across manifest, effective resolver, hosted Admission, resource loader, Marketplace UI, and product artifact verifier tests. +- `pnpm exec tsc --noEmit`: pass. +- Source evidence: broader adjacent 133 passed; full diagnostic 1,781 passed/2 skipped with two transient Windows `spawn EBUSY` tests passing 3/3 alone; pressure 1/1; Vite all targets pass. +- Windows NSIS package and `verify:artifact:win`/`verify:artifact:pi` pass from source `b1657300f29f9744551f31029eb192ee82f01b92`. Installer: 208,297,578 bytes; SHA-256 `449288AF079E030F3F700F0FF1701971F7AB4806E0366CE989F4E8F2FE33ACA3`. + +## Follow-ups + +- Install the rebuilt client; the currently running installed binary cannot gain bundled resources from a source merge alone. +- Pair it with a server deployment at migration 0078. Provider/pricing readiness remains server-owned and is not implied by catalog usability. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-package-121-7a3c.md b/.project-docs/30-worklog/tasks/20260901-package-121-7a3c.md new file mode 100644 index 0000000..bd8fd73 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-package-121-7a3c.md @@ -0,0 +1,53 @@ +# Task: Build 1.2.1 from newest main + +## Identity + +- Task ID: 20260901-package-121-7a3c +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 143aaec3d6770dd263bf1e418e89bf722989caef +- Owner: codex +- Status: Completed + +## Scope + +- Build and verify the Windows x64 1.2.1 installer from the latest local `main` commit (`143aaec3d6770dd263bf1e418e89bf722989caef`). No source changes are in scope; the deliverable is the packaged artifact and its verification evidence. + +## Intent And Constraints + +- Use the pinned pnpm toolchain and frozen lockfile. Produce the unsigned Windows x64 NSIS installer, preserving the repository's existing Electron/Pi runtime packaging contracts and current main-branch behavior. +- On 2026-09-01 the human explicitly authorized `codex-root` to take over this + completed task solely to preserve and commit its known untracked task record, + then release the occupied `main` worktree for the separately verified + Conversation-abort integration. Do not rebuild, relabel, overwrite, or otherwise + change the existing 1.2.1 artifact during this closure. + +## Outcome + +- Produced `release\Makelore-1.2.1-win-x64.exe` from `main` at `143aaec3d6770dd263bf1e418e89bf722989caef` (295,115,852 bytes). +- Packaged artifact contains version `1.2.1`, Learning code, Web Search and Marketplace code, `.makelore/project.json` integration, and no legacy `learning-player` files. +- Pi product artifact verification passed for Pi `0.84.2`, 130 packages, 6 assets, packaged plugin/runtime closure, and resolver/runtime checks. +- Human-authorized closure preserved the task record on `main` without changing + product source, dependencies, release output, installed data, or artifact bytes. + +## Verification + +- `pnpm install --frozen-lockfile` passed. +- `pnpm run package:stage:win-x64` passed. +- Electron Builder Windows x64 build passed. +- `pnpm run verify:publish-runtime` passed (npm `11.6.2`). +- `pnpm run verify:artifact:win -- --allow-dirty --installer .\\release\\Makelore-1.2.1-win-x64.exe` passed; Electron `43.4.0`, Node `24.18.1`, Python/uv/npm resources and native modules verified. +- `pnpm run verify:artifact:pi -- --app-exe .\\release\\win-unpacked\\Makelore.exe --samples 2` passed; final result `pass` with cold/warm readiness and packaged closure evidence. +- ASAR inspection passed: version `1.2.1`, `learningEnabled=true`, Web Search/Marketplace/config markers present, `legacyLearningPlayerFiles=false`. +- Installer SHA-256: `3578A3643BF715C57EF924D75819240D97D2387DB7300546735384DA06B26E49`; Authenticode status `NotSigned` (local unsigned build). +- `smoke:pi:real` and `perf:pi:release` were not rerun; their existing harness still asserts two parent process IDs while current main intentionally uses one long-lived parent Agent Server with logical runs/leases. + +## Follow-ups + +- External code signing and publication remain follow-ups if a distributable signed release is required. +- Align the Pi smoke/performance harness assertion with the one-parent Agent Server architecture before claiming those checks as passing. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-plugin-catalog-main-integration-8e5c2a91.md b/.project-docs/30-worklog/tasks/20260901-plugin-catalog-main-integration-8e5c2a91.md new file mode 100644 index 0000000..66a656b --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-plugin-catalog-main-integration-8e5c2a91.md @@ -0,0 +1,60 @@ +# Task: Integrate and package Marketplace weak-ETag fix + +## Identity + +- Task ID: 20260901-plugin-catalog-main-integration-8e5c2a91 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 5e8b7266c2a8297c9d307b576256886659d44780 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate the completed weak-ETag Marketplace client fix from source commit `b3cfe7e1ceb7da65ccc99214db09102b4fc1cace` into local `main`. +- Reconcile the accepted fact into current project state without changing Marketplace architecture or authority boundaries. +- Rebuild and verify the Windows 1.2.0 artifact, then replace the currently installed client and verify the Plugin Center against the deployed catalog. + +## Intent And Constraints + +- The user explicitly authorized release/takeover of completed root task `20260901-package-120-4c7e`, integration, rebuild, and installation. +- Preserve the released task's untracked record `.project-docs/30-worklog/tasks/20260901-package-120-4c7e.md` byte-for-byte; to satisfy the Integration Gate it was moved without modification to `C:\Users\7brot\AppData\Local\Temp\codex-task-recovery-20260901-package-120-4c7e\20260901-package-120-4c7e.md` and remains outside this task's commit. +- Source task promotion candidates are empty and its sole parent is the exact current `main` base, so no semantic conflict or merge choice exists. +- Do not push, publish, deploy, mutate the Works Square catalog, or weaken trust/Provider/billing fail-closed behavior. +- Installation may stop the running MakeLore process, but must target only the exact generated MakeLore installer/application. + +## Plan + +1. Cherry-pick the exact source fix and remove only the duplicate source task record from the integration history. +2. Update canonical current state and run focused/full source verification from the integrated tree. +3. Build and verify the Windows artifact, stop MakeLore, install the exact artifact, and smoke the Plugin Center. +4. Record artifact/install evidence, complete project-document gates, and leave `main` clean except the preserved foreign task record. + +## Outcome + +- Product source commit `b3cfe7e1ceb7da65ccc99214db09102b4fc1cace` was integrated as local `main` commit `38f2358`; canonical integration checkpoint is `6083de6aee8942a78191ed18a00bfd9f4ba0902d`. +- A Windows 1.2.0 artifact was rebuilt from that exact checkpoint, verified, installed over the prior 1.2.0 at `D:\Tools\泥土\niancode\Makelore`, and restarted with the existing signed-in session. +- The installed Plugin Center now renders exactly three deployed official Plugins—Data Service, Game Resource, and Web Search—and no longer shows `plugin_backend_unavailable`. +- Game Resource and Web Search are listed but still show no stable Release. That is the existing production signing/publication activation hold, not a catalog-loading regression; this task did not bypass it. + +## Verification + +- Source commit sole parent equals integration base `5e8b7266c2a8297c9d307b576256886659d44780`; cherry-pick completed without conflict. +- Integrated focused Marketplace suite: 4 files / 68 tests passed; `pnpm run typecheck` passed. +- `pnpm run package:stage:win-x64` and electron-builder with `--config.extraMetadata.version=1.2.0` passed. The first aggregate download attempt stopped before build on an external GitHub `ECONNRESET`; staging reused the previous verified uv 0.10.0 binary (SHA-256 `5E559E322AD2F2E25E7D9C3CB51E3891AB0676A7E7B59EA250A021E4CB2F6E31`) and did not change tracked source. +- `pnpm run verify:publish-runtime`: passed with npm 11.6.2. +- `pnpm run verify:artifact:win -- --installer .\release\Makelore-1.2.0-win-x64.exe`: passed with embedded `gitCommit`/`verificationHead` `6083de6aee8942a78191ed18a00bfd9f4ba0902d`, Electron 43.4.0, Node 24.18.1, Python, uv, npm, native modules, and Unicode proof. +- `pnpm run verify:artifact:pi -- --app-exe .\release\win-unpacked\Makelore.exe --samples 2`: passed; Pi 0.84.2 closure, Marketplace markers, Web Search route/receipt authority, and bundled Data Service ten-tool catalog were present. +- Final installer: 294,864,542 bytes; SHA-256 `1301AE189BCBD44AA0E373982981E80B324EC45CDDF8F30415C2810F68319C06`. +- Final `app.asar`: 136,144,898 bytes; SHA-256 `5024322B112DC1839E306EB45953D216A4622149341A89E6BEE35018EDABEB1B`; the active Main bundle contains the optional weak `W/` composite ETag prefix and is selected by `dist-electron/main/index.js`. +- Silent installer exit code was 0. Installed `Makelore.exe` and `app.asar` hashes exactly matched `release/win-unpacked`; installed version remained 1.2.0. +- Signed-in installed-client UI smoke opened Code → Plugin Center and displayed `3 个插件`: Data Service, 游戏资源生成, and 联网搜索, with no catalog-unavailable error. Screenshot: `C:\Users\7brot\AppData\Local\Temp\makelore-plugin-center-installed-fixed.png`. + +## Follow-ups + +- If Game Resource and Web Search must become acquirable rather than merely listed, complete the separate official signing key, signed stable Release, and production publication activation gate. Do not infer that authority from this client transport fix. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-promote-local-runtime-main-a7c4e291.md b/.project-docs/30-worklog/tasks/20260901-promote-local-runtime-main-a7c4e291.md new file mode 100644 index 0000000..7c60021 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-promote-local-runtime-main-a7c4e291.md @@ -0,0 +1,72 @@ +# Task: Promote packaged Pi runtime fix to main + +## Identity + +- Task ID: 20260901-promote-local-runtime-main-a7c4e291 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 7f0e9310a7f394249fab3cadc5c82d70f91cbfeb +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Fast-forward the exact verified packaged Pi Agent Server resolver candidate + `42ea83c0cbad52432eca99e4161e0360bef3e219` from local `main` base + `7f0e9310a7f394249fab3cadc5c82d70f91cbfeb`. +- Preserve the candidate's product tree and accepted canonical facts while keeping + source and intermediate integration task records on their owning branches. +- Complete the task-aware documentation gate and release `main` ownership from a clean + worktree; do not push, publish, package, or deploy. + +## Intent And Constraints + +- Pi `0.84.2` remains the only production Coding runtime; Electron Main remains the + Agent Server owner, and the bundle layout, Provider/Renderer contracts, recovery + behavior, and public APIs remain unchanged. +- The currently installed client predates this fix. Local source integration must not + be represented as a rebuilt or installed-package acceptance result. +- Concurrent Task Gate passed after the previous clean, ready feature owner of `main` + was released non-forcibly. This task exclusively owns `main` and the Integration Lock. +- Planning and Integration Gates passed using the source outcome, verified candidate, + current-state, ADR-006, evidence index, commitment ledger, and exact Git ancestry; + no semantic conflict or human product-direction decision was present. + +## Outcome + +- Fast-forwarded local `main` from `7f0e9310a7f394249fab3cadc5c82d70f91cbfeb` + to verified integration candidate `42ea83c0cbad52432eca99e4161e0360bef3e219` + with no merge conflict or replay. +- Promoted the packaged Pi Agent Server resolver fix and production-shaped regression + to local `main`; canonical current state and evidence now identify this promotion task. +- Omitted the duplicate intermediate integration task record from the promotion tip; + it remains recoverable unchanged at candidate `42ea83c0cbad52432eca99e4161e0360bef3e219`, + while the source task record remains at source `7df245af5a04f62be48980831ff41987ba686009`. + +## Verification + +- Pre-promotion ancestry check passed: local `main` was an ancestor of the candidate, + with `0` main-only and `3` candidate-only commits. +- `git merge --ff-only` succeeded and placed `main` at exact candidate `42ea83c` before + this promotion ledger commit. +- Candidate verification carried forward unchanged: 4 focused files / 24 tests passed; + typecheck passed; lint passed with 0 errors and 5 pre-existing warnings; Vite/Electron + Main/Preload/utility build passed. Source full suite passed 215 files / 1,761 tests with + 2 conditional skips. +- At promotion ledger `e1e4b570f98a5c159a01fda740222ce3cd1d755e`, the product tree + exactly matched verified candidate `42ea83c`, the candidate remained an ancestor, + task-aware drift and project-docs structure checks passed, the net task diff contained + only the two product files plus canonical/own-task memory, and `git status` was clean. +- This closure changes only this task's status and verification record; the same drift, + structure, ancestry, product-tree, and clean-status checks are rerun before release. + +## Follow-ups + +- Rebuild and install the Windows artifact, run the publish/runtime/artifact/final + packaged proof, and confirm the first Coding Conversation no longer reports local + runtime unavailable. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-reasoning-effort-client-diagnosis-b6c4e1a2.md b/.project-docs/30-worklog/tasks/20260901-reasoning-effort-client-diagnosis-b6c4e1a2.md new file mode 100644 index 0000000..36a18b6 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-reasoning-effort-client-diagnosis-b6c4e1a2.md @@ -0,0 +1,90 @@ +# Task: Diagnose reasoning-effort client capability + +## Identity + +- Task ID: 20260901-reasoning-effort-client-diagnosis-b6c4e1a2 +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 850947c092892cb647c4191b6d8bbf37a763e1ad +- Owner: codex +- Status: Ready for integration + +## Scope + +- Diagnose why the current AI Programming model menu exposes only one reasoning + strength for a model that is expected to support multiple strengths. +- Trace the committed Makelore chain from model/provider capability metadata through + Conversation state, Pi runtime mapping, Snapshot projection, and Renderer options. +- Build and run a deterministic focused reproduction for the exact menu symptom. +- Report the verified boundary and the smallest coordinated repair scope; do not + modify product behavior unless the user separately asks for a fix. + +## Intent And Constraints + +- Treat the current Pi 0.84.2 hard-cutover architecture and Main-owned Provider + boundary as authoritative; do not restore OpenCode or add a compatibility layer. +- Distinguish committed source behavior, deployed/model-provider behavior, and + unverified assumptions. The screenshot proves the visible symptom only. +- Keep credentials, real user data, and upstream payloads out of diagnostics. +- Preserve concurrent task ownership. The potentially related historical model-parity + peer has no defined scope, so this task remains read-only outside its own record. +- The one-api repository is separately gated; do not inspect its occupied worktree or + initialize missing project-doc assets without authorization. + +## Outcome + +- Confirmed the direct cause in `shared/imported-model-profile.ts`: the + `deepseek-v4-pro` profile marks `off`, `minimal`, `low`, and `medium` as + unsupported (`null`) and maps only product `high` to provider `high`. +- Confirmed Pi 0.84.2 filters every `null` level and exposes `xhigh`/`max` only when + those keys exist. The committed profile therefore deterministically projects the + single level `['high']`. +- Confirmed the Main/runtime and Renderer chain is behaving as designed: provider + config copies the profile map, the Pi session reports its supported levels, and + the composer renders only levels in that runtime list. The Renderer is not + dropping additional options. +- Confirmed the local AI proxy forwards the original JSON request body to one-api; + it does not remove `reasoning_effort`. +- A one-variable diagnostic map exposed `off`, `low`, `medium`, and `high`, and the + real Pi serializer emitted provider values `low`, `high`, and `max` for product + `low`, `medium`, and `high` respectively. This proves the repair seam without + changing production behavior. +- The product contract currently has only `off|minimal|low|medium|high`; literal + `xhigh`/`max` menu labels require a separate contract/UI expansion. Mapping the + existing product `high` to provider `max` avoids that expansion if those product + semantics are accepted. +- No product code or canonical project documentation was changed. Temporary + diagnostic tests were removed after use. + +## Verification + +- Deterministic red reproduction, run three times: + `pnpm exec vitest run tests/unit/diagnostic-deepseek-v4-reasoning.test.ts --maxWorkers=1` + -> each run failed with expected `['off','low','medium','high']` versus actual + `['high']`. +- One-variable causal test using only a replacement level map -> passed and returned + `['off','low','medium','high']`. +- Real Pi payload serialization diagnostic -> `3 passed`; product + `low -> low`, `medium -> high`, and `high -> max`, with thinking enabled. +- Focused proxy diagnostic with `reasoning_effort: 'max'` -> passed; the temporary + assertion was reverted. +- Final existing-suite check: + `pnpm exec vitest run tests/unit/imported-model-profile.test.ts tests/unit/pi-provider-config.test.ts tests/unit/coding-feature-ui.test.tsx tests/unit/ai-proxy-routes.test.ts --maxWorkers=1` + -> `4` files and `50` tests passed. + +## Follow-ups + +- Update the `deepseek-v4-pro` profile and its focused tests after confirming the + desired product labels. The smallest compatible map is expected to expose product + low/medium/high while translating to provider low/high/max. +- If the UI must display literal `max` (or `xhigh`) rather than translating the + existing `high`, extend `ThinkingLevel`, IPC/runtime contracts, labels, and tests + as one coordinated change. +- A server-owned capability descriptor can reduce future profile staleness, but is + a larger ownership change and is not needed for the immediate fix. + +## Promotion Candidates + +- None. Promote a durable capability-ownership rule only with an implemented repair + or accepted server/client contract design. diff --git a/.project-docs/30-worklog/tasks/20260901-web-search-client-main-merge-5a9d3b82.md b/.project-docs/30-worklog/tasks/20260901-web-search-client-main-merge-5a9d3b82.md new file mode 100644 index 0000000..87b095d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-web-search-client-main-merge-5a9d3b82.md @@ -0,0 +1,69 @@ +# Task: Merge reviewed Web Search client to main + +## Identity + +- Task ID: 20260901-web-search-client-main-merge-5a9d3b82 +- Mode: Integration +- Branch: codex/20260901-web-search-client-main-merge-5a9d3b82-web-search-client-main-merge +- Worktree: D:\Datas\OthersProjects\makelore-web-search-main-merge-5a9d3b82 +- Base commit: 0a86ec825a5803bf7e037d3b23c39be23238c43d +- Owner: web-search-client-main-merger +- Status: Ready for Integration + +## Scope + +- Promote the complete reviewed native Web Search client chain from released coordinator + closure `ae81949a49d7df3be4859e6c111235a991a504e4` onto local `main` from exact clean + base `0a86ec825a5803bf7e037d3b23c39be23238c43d`. +- Preserve product-tree equality with the reviewed coordinator, replace only the + coordinator-owned ledger copy with this main-merge audit record, and retain package + evidence plus all XWS, paid OpenAI, PostgreSQL and production-activation HOLDs. + +## Intent And Constraints + +- Concurrent Task Gate: Passed. This integration task owns its isolated worktree, + branch, exact base and the repository integration lock. No other active integration + writer exists; the completed root inspection and review/source tasks are read-only. +- Planning Gate: Passed after reading client AGENTS, the maintain-project-docs workflow, + project agent-entry/startup memory, architecture/decisions, the released Web Search + coordinator outcome, and the user-authorized main-merge boundary. +- Integration plan: verify ancestry and root cleanliness; fast-forward only to the + released coordinator closure; remove only the duplicated released coordinator task + record while preserving its source branch; record the main landing here/current-state; + verify product-tree equality, docs drift, diff, clean status and reviewed client tree; + then fast-forward local `main`, complete and release this task. +- Never reset, stash, clean, push, create a PR, deploy, publish, run paid OpenAI, or + represent the external PostgreSQL/XWS groups as passed. + +## Outcome + +- The isolated merge branch fast-forwarded from exact root base + `0a86ec825a5803bf7e037d3b23c39be23238c43d` to released coordinator closure + `ae81949a49d7df3be4859e6c111235a991a504e4` with no conflict or semantic replay. +- The duplicate coordinator task record is removed only from this promotion branch; + its released source branch remains intact. Canonical current state now attributes the + local `main` landing to this integration task while retaining source/product/review, + exact package evidence, and external HOLD facts. + +## Verification + +- Root `main` was clean at `0a86ec825a5803bf7e037d3b23c39be23238c43d`; + that commit is an ancestor of coordinator closure `ae81949a49d7df3be4859e6c111235a991a504e4`. +- The merge branch reached the exact coordinator closure by `git merge --ff-only`. +- All non-`.project-docs` paths are exact-equal to released coordinator closure + `ae81949a49d7df3be4859e6c111235a991a504e4`; only canonical/task ledger paths differ. +- `check_project_docs`, task-aware `check_doc_drift`, fixed-range `git diff --check`, + and clean worktree checks passed. Exact product-tree equality adopts the reviewed + `1,759 passed / 2 skipped`, pressure `1/1`, typecheck, lint, Windows package and + artifact verification evidence without rebuilding an unchanged product tree. +- No live PostgreSQL or OpenAI command was run. + +## Follow-ups + +- Complete the final root `main` fast-forward only if it is still clean at the exact + recorded base, then complete and release this main-merge task. Keep XWS/PostgreSQL, + paid OpenAI and production activation on HOLD. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-web-search-stale-integration-9c5f3d82.md b/.project-docs/30-worklog/tasks/20260901-web-search-stale-integration-9c5f3d82.md new file mode 100644 index 0000000..e802558 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-web-search-stale-integration-9c5f3d82.md @@ -0,0 +1,84 @@ +# Task: Integrate Web Search frozen worker stale-Library fix + +## Identity + +- Task ID: 20260901-web-search-stale-integration-9c5f3d82 +- Mode: Integration +- Branch: codex/20260901-web-search-stale-integration-9c5f3d82 +- Worktree: D:\Datas\OthersProjects\makelore-web-search-stale-integration-9c5f3d82 +- Base commit: 697c68974de8bf43c7094e67372a03123c449343 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate sole source commit `3620cdc277fa0a99757c57a549008f9ecd380558` + from completed feature task `20260901-web-search-stale-resources-8b4e2c71` + onto exact local `main` base `697c68974de8bf43c7094e67372a03123c449343`. +- Preserve the source task record on its source branch, remove only its duplicate copy + from this integration checkpoint, update this integration record and canonical + current state, and fast-forward local `main` after verification. + +## Intent And Constraints + +- Concurrent Task Gate: passed in the isolated integration worktree created from exact + `main`; this task holds the integration lock. The root worktree's two unrelated + untracked package records remain untouched. +- Planning Gate: passed after loading the required project memory earlier in the + diagnostic task, then re-reading the current integrated state, Integration Gate, + completed source record, and active-owner registry for this exact promotion. +- Semantic comparison found no conflict. The source fix preserves the accepted frozen + worker and server Admission boundaries: new workers still require current Library; + only invocation revalidation may use the last verified stale Library projection. +- Integrate only the sole source commit. Do not restart the running installed client, + package, push, deploy, publish, call paid Web Search, or alter server/pricing/Provider + authority. + +## Project Context Loaded + +- Integration task/worktree/branch/base identity matches the task registry exactly. +- Source commit has sole parent equal to this integration base and is + `READY_FOR_INTEGRATION`; its focused, adjacent, typecheck, lint, full-unit, pressure, + diff, and documentation gates passed. +- The old Web Search coordinator/reviewer registrations are historical at earlier + frontiers and do not own this product range. The root's package records are unowned + and intentionally excluded by isolated integration. +- Canonical current state already records frozen Web Search worker materialization, + assignment, Admission, and installed-binary limitations. This landing adds only the + diagnosed stale-Library invocation correction and its evidence. +- Gate result: passed; cherry-pick and canonical integration writes may proceed. + +## Outcome + +- Cherry-picked sole source commit `3620cdc277fa0a99757c57a549008f9ecd380558` + onto the exact integration base as product commit + `40e1912`. The four product/test paths are byte-identical to the completed source. +- Removed only the duplicated source task record from this integration checkpoint; its + source branch/worktree/record remain intact. Recorded the landed behavior and limits + at the top of canonical `current-state.md`. +- The fix keeps new hosted workers fail closed on stale Library data while letting an + already verified frozen worker continue to the server-owned exact Release Admission. + The project, account, acquisition, suspension, Release/version, policy, billing, + confirmation and parent-only checks were not bypassed. +- Local `main` can be fast-forwarded after this documentation checkpoint. Its two + unrelated untracked package records remain untouched. The running installed MakeLore + binary was not restarted or replaced and therefore does not yet contain this source. + +## Verification + +- Source task evidence adopted: focused 19, adjacent 55, full 215 files / 1,786 passed / + 2 skipped plus pressure 1/1, typecheck, scoped lint, diff, and task documentation + gates passed. +- Integration product/test tree equality against source commit: passed. +- Integration rerun: focused resolver/registry 2 files / 19 tests passed; `pnpm run + typecheck` passed. +- No paid Web Search, package, restart, push, deploy, publication, server or Provider + action was performed. + +## Follow-ups + +- None recorded. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260901-web-search-tool-routing-integration-7a4e1c93.md b/.project-docs/30-worklog/tasks/20260901-web-search-tool-routing-integration-7a4e1c93.md new file mode 100644 index 0000000..da0a6c6 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260901-web-search-tool-routing-integration-7a4e1c93.md @@ -0,0 +1,70 @@ +# Task: Integrate Web Search Agent assignment catalog fix + +## Identity + +- Task ID: 20260901-web-search-tool-routing-integration-7a4e1c93 +- Mode: Integration +- Branch: codex/20260901-web-search-tool-routing-integration-7a4e1c93 +- Worktree: D:\Datas\OthersProjects\makelore-web-search-tool-routing-integration-7a4e1c93 +- Base commit: 08099ea1c2ea24efc10c20f878fc25a557ea9d90 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate sole source commit `612794463de1b14fd748202a00115c6f93c7346b` + from completed feature task `20260901-web-search-tool-routing-fix-6f9d3b82` + onto exact local `main` base `08099ea1c2ea24efc10c20f878fc25a557ea9d90`. +- Preserve the source task record as read-only evidence, update only this integration + task record and the canonical integrated-state ledger, and fast-forward local + `main` after verification. + +## Intent And Constraints + +- Concurrent Task Gate: Passed in a dedicated integration worktree/branch from the + exact current `main`; the repository integration lock is held by this task. +- Planning Gate: Passed after loading the required memory entry points, current + state, decision index, architecture/data flow/domain rules, evidence, reflection, + commitments and stale items, plus the completed source task record. +- Semantic comparison found no conflict: the fix preserves the accepted separation + between project enablement, Agent assignment, and effective frozen worker state. + It only restores enabled plugin Skills to the unscoped assignment catalog. +- Source product/test/task files are integrated as one exact commit. No server, + policy, billing, Provider, package version, deployment, publication, or current + worker hot-swap is in scope. The two unrelated untracked packaging records in the + user root must remain untouched. + +## Outcome + +- Cherry-picked the sole completed source commit as product commit + `adc28db7855f8b1770f1c1d46af77b7cc88cfa1a`; its product/test files are + unchanged. The duplicate foreign source task record is retained on the source + branch and omitted from this integration checkpoint as required by the + project-document ownership boundary. +- Integrated the assignment-catalog correction without changing the accepted + project-enable → Agent-assignment → effective-worker gates. Unscoped Skill + discovery now uses project-enabled Plugin IDs and avoids Admission resolution; + Agent-scoped discovery remains resolver-authoritative. +- Updated the canonical current-state ledger with the source/product commits, + verified behavior, and existing-worker freeze boundary. No promotion candidate, + semantic conflict, or ADR/architecture change was required. + +## Verification + +- Adopted the exact-tree source evidence: focused 5 files / 45 tests, full 215 files / + 1,785 passed / 2 skipped plus pressure 1/1, TypeScript, and scoped ESLint passed. +- Re-ran the integrated tree's focused 5 files / 45 tests and `pnpm run typecheck`; + both passed. +- Exact product/test diff equality, exact parent base, `git diff --check`, task-aware + project-doc drift, clean-worktree, and local `main` fast-forward are final gates. + +## Follow-ups + +- The running installed 1.2.3 binary predates the assignment-picker source fix. + The reported project's exact Agent assignment has been corrected separately; + a new Conversation is required because existing Pi workers intentionally freeze + their Skill/tool snapshot. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260902-design-youth-client-integration-7a6d3c92.md b/.project-docs/30-worklog/tasks/20260902-design-youth-client-integration-7a6d3c92.md new file mode 100644 index 0000000..6f8c4cf --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-design-youth-client-integration-7a6d3c92.md @@ -0,0 +1,136 @@ +# Task: Integrate youth-facing AI Design client changes + +## Identity + +- Task ID: 20260902-design-youth-client-integration-7a6d3c92 +- Mode: Integration +- Branch: codex/20260902-design-youth-client-integration-7a6d3c92-design-youth-client-integration +- Worktree: D:\Datas\OthersProjects\makelore-design-youth-integration-7a6d3c92 +- Base commit: 7552cf59526449c29d663a769c0fb62d84a1a759 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate reviewed Design Youth source commit + `0fd32a2d49045a8f9e7f2e19ba6477f48f93e30c` onto the current client frontier + `7552cf59526449c29d663a769c0fb62d84a1a759`. +- Replace the default professional field matrix with conversation plus one compact + “我的创作” card for creators aged 8-16, while retaining contextual “精细调整”. +- Preserve the V2 Current Specification reducer, immutable Quote confirmation, + Task/Asset reconciliation, stable operation identities, and Main-owned transport. +- Own the integration record, canonical current-state reconciliation, cross-frontier + verification, and safe promotion to local `main`. +- Correct the current frontier's eager CommonJS load of the ESM-only Pi package + manager, which the integrated Electron E2E exposed before any Design page opened. + +## Intent And Constraints + +- One understandable default serves creators aged 8-16; there is no separate age or + expert mode. Advanced controls remain progressive disclosure over canonical state. +- Direct choices use existing typed commands. Renderer drafts, assistant deltas, + Quote/Task events, and localized copy never become Specification authority. +- Known compiler/Task issues render code-based youth-facing Chinese copy; unknown + messages use a fixed safe fallback rather than raw server or Provider text. +- Do not add or change a database migration, DTO/API, semantic store, compatibility + layer, Provider route, pricing path, deployment, publication, push, or paid request. +- Keep the Pi package-manager correction limited to loading its existing dependency + on demand for remote Device Package installation; do not change package semantics. +- Preserve the two user-owned untracked records in the root checkout exactly as-is: + `20260901-package-122-c5e8.md` and `20260901-package-123-d7f3.md`. +- The source task record is read-only evidence. The integrated default branch will + retain this integration task and canonical memory, not import a second active task + ownership record. + +## Concurrent Task Gate + +- Task ID: `20260902-design-youth-client-integration-7a6d3c92` +- Mode: Integration +- Branch: `codex/20260902-design-youth-client-integration-7a6d3c92-design-youth-client-integration` +- Worktree: `D:\Datas\OthersProjects\makelore-design-youth-integration-7a6d3c92` +- Base commit: `7552cf59526449c29d663a769c0fb62d84a1a759` +- Ownership result: Claimed in an independent linked worktree. The preceding Model + Tools integration lock is complete and released; its exact client frontier is this + task's base commit. +- Other local state: root `main` contains only the two allowlisted user-owned untracked + task records. They are outside the source commit's changed paths and remain untouched. +- Gate result: Passed. + +## Project Context Loaded + +Read: +- the startup memory set, memory index, project positioning, success criteria, + decision index, current state, system overview, business rules, and ADR-007; +- source task `20260902-design-youth-client-7c5e3a82`, its exact commit boundary, + verification evidence, follow-ups, and promotion candidates; +- current Model Tools integration record and its released frontier. + +Relevant understanding: +- The Current Specification remains the only semantic authority. The Creation Card + and Fine Tune drawer are projections/editors over the existing V2 reducer. +- Main remains the only Works Square transport and secret owner. Generation confirms + only an immutable Quote ID; public copy must not expose Provider internals. +- Youth readiness language may offer to check a production plan, but must not claim + server readiness before concrete Quote blockers have been evaluated. +- Source verification already covers responsive composition, direct edit, chat, + Quote/Task flow, stable Shot identities up to 24, terminal Quote-blocked settlement, + safe issue copy, typecheck, lint, build, broad tests, and Electron E2E. +- The new frontier changes Coding model tools/device packages and canonical memory, + not the ImageCanvas product/test files. Canonical documentation must be reconciled + in this integration task rather than overwritten from the older source base. +- Planning Gate result: Passed. + +## Outcome + +- Integrated reviewed source `0fd32a2d49045a8f9e7f2e19ba6477f48f93e30c` + onto the exact released Model Tools frontier with merge + `16157388afc2f6103aaee0bfd3c4e4e80c8fec48` and no source conflict. +- Canvas now presents one conversation-first “我的创作” card, A/B/C youth choices, + “你帮我选”, responsive single mounting, and contextual “精细调整” while the + server-owned Current Specification remains the only semantic authority. +- Quote blockers are revision-bound and terminal blocked responses/events settle + pending operations. Compiler and Task issues use code-based youth-facing Chinese + copy with fixed unknown fallbacks instead of raw technical messages. +- Fine Tune preserves stable Shot ids, timing/camera/framing/motion/focus/transition + metadata, supports the full 24-Shot server contract, and treats a cleared beat as + explicit deletion rather than submitting an invalid null value. +- The integrated Electron check exposed a pre-existing current-frontier startup + blocker before the Design page opened: Vite externalized an ESM-only static Pi + package-manager import as top-level CommonJS `require`. Commit + `c5447ae90c434c06dbafa724b229d8459cb829d0` now dynamically imports that existing + module only for remote Device Package installation; package semantics are unchanged. +- Removed the imported source task record from default-branch ownership scope; this + integration record and canonical current state contain the promoted evidence. +- No database migration, public DTO/API, second semantic store, compatibility layer, + Provider activation, paid generation, package, deployment, publication, push, or + PR was added or performed. + +## Verification + +- `corepack pnpm exec vitest run tests/unit/youth-form-projection.test.ts tests/unit/youth-creation-card.test.tsx tests/unit/youth-issue-copy.test.ts tests/unit/fine-tune-drawer.test.tsx tests/unit/image-workspace-store.test.ts tests/unit/image-canvas-page.test.tsx` — 6 files / 40 tests passed on the integrated frontier. +- `corepack pnpm exec vitest run tests/unit/device-package-manager.test.ts` — 5 tests + passed after the ESM loading correction. +- `corepack pnpm run typecheck` — passed before and after the correction. +- Scoped ESLint over all Design Youth source/tests and the corrected Device Package + manager — passed. +- `corepack pnpm run build:vite` — passed; only existing chunk-size and mixed + static/dynamic import warnings were reported. The active Main chunk contains the + expected dynamic Pi import and no eager Pi package-manager `require`. +- The first Electron run deterministically failed 2/2 at app launch with + `ERR_PACKAGE_PATH_NOT_EXPORTED`, before either test reached Canvas. After + `c5447ae`, the identical + `corepack pnpm exec playwright test tests/e2e/image-workspace-v2.spec.ts` run passed + 2/2 in 3.8 seconds, covering Prompt Museum isolation plus desktop/375 px/768 px + Design conversation, direct edit, Quote confirmation, and Task flow. +- `check_project_docs.py` and task-aware `check_doc_drift.py` — passed after the + imported source task record was removed from the final integration tree. + +## Follow-ups + +- Run moderated usability sessions with creators aged 8-16 after packaging. +- Production package/deployment and real-account Design V2 smoke remain separate + release gates; paid Provider activation still requires explicit authorization. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260902-model-tools-client-integration-5d8b2f73.md b/.project-docs/30-worklog/tasks/20260902-model-tools-client-integration-5d8b2f73.md new file mode 100644 index 0000000..33aadb2 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-model-tools-client-integration-5d8b2f73.md @@ -0,0 +1,148 @@ +# Task: Model Tools and Device Packages client integration + +## Identity + +- Task ID: 20260902-model-tools-client-integration-5d8b2f73 +- Mode: Integration +- Branch: codex/20260902-model-tools-client-integration-5d8b2f73-model-tools-client-integration +- Worktree: D:\Datas\OthersProjects\makelore-model-tools-integration-5d8b2f73 +- Base commit: 841273b43354b2237a52a7081a9fceb647ef4e3f +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Implement the MakeLore half of the accepted Model Tools and Device Packages + specification from exact base `841273b43354b2237a52a7081a9fceb647ef4e3f`. +- Add the closed selected-model `web_search` tool, a Main-owned conversation-driven + Device Package manager, immutable parent-worker resource materialization, and a + split Official Plugins / Device Installed view in My Plugins. +- Remove the old hosted Web Search client/package path only after the new model-tool + composition is working; keep Game Resource and other official plugins unchanged. +- Own the client integration record and final canonical-memory reconciliation. + +## Intent And Constraints + +- There is no visible install/source-picker entry. Installation is initiated by + conversation tools, requires a preview and a distinct later user-confirmation + turn, and accepted packages default to enabled for the next parent generation. +- Device Packages are local to this OS user and never join Account Library, + Admission, Release, Channel, or Marketplace Package Store state. +- Executable Pi extensions run with desktop-user authority; the confirmation must + state that plainly. P0 does not claim a sandbox or load packages into child agents. +- Web Search uses the frozen selected model/provider/credential and never falls back + to `agent_browser`, a separate model, hosted Web Search client, or Plugin Charges. +- The two user-owned untracked files in `D:\Datas\OthersProjects\makelore` remain + untouched; all product work occurs in this linked worktree. +- No live paid Provider request, production install, publish, push, or PR is + authorized by this implementation task. + +## Concurrent Task Gate + +- Task ID: `20260902-model-tools-client-integration-5d8b2f73` +- Mode: Integration +- Branch: `codex/20260902-model-tools-client-integration-5d8b2f73-model-tools-client-integration` +- Worktree: `D:\Datas\OthersProjects\makelore-model-tools-integration-5d8b2f73` +- Base commit: `841273b43354b2237a52a7081a9fceb647ef4e3f` +- Ownership result: Claimed in an independently created linked worktree; the client + integration lock belongs to this task. +- Other local registry entries are historical isolated planning/ready tasks; none + owns this worktree or the planned model-tool/device-package files. The two + untracked root records were neither copied as changes nor claimed. +- Gate result: Passed. + +## Project Context Loaded + +Read: +- `.project-docs/05-agent-entry/{read-before-coding,concurrent-task-gate,planning-gate,memory-index}.md` +- project positioning/success criteria, current state, decision index, architecture, + data flow, business rules/glossary, evidence/reflection indexes, commitments and + stale-items registry +- the accepted server-repository design and implementation Spec, proposal, and + completed design task for the three-domain cutover +- current Pi worker/resource loading, official Plugin resolver/Package Store, + atomic JSON utilities, product composition, model capability parser, My Plugins, + and their focused tests identified by the Spec + +Relevant understanding: +- Project goal: Electron Main owns credentials, local package bytes, effective + resources, and worker generations; Renderer and Agent inputs receive only closed + projections. +- Current focus: make Web Search a selected-model core tool and let conversation + install local Skills/Pi extensions that appear under Device Installed. +- Active constraints: Pi `0.84.2`, explicit `--extension`/`--skill` loading with + discovery disabled, atomic durable index, no lifecycle scripts, parent-only P0, + and safe refresh after the current turn. +- Decisions affecting this task: no visible installer, automatic post-confirmation + loading, Official/Device inventory split, no Web Search Marketplace card, and no + dual hosted/model-tool runtime. +- Evidence/commitments: current Pi runtime and Marketplace boundaries are already + integrated; exact packaged and live Provider acceptance remains a later gate. +- Likely modules: imported model/profile parsing, Pi provider/runtime composition, + new `electron/coding-packages/**`, effective resources/worker lifecycle, Main-local + management routes, My Plugins store/page, conversation protocol and artifact tests. +- Unknowns/conflicts: provider-native Web Search must remain unavailable for any + selected model lacking a proven closed capability; a third-party package manifest + alone is not evidence that its tool supports Bailian/DeepSeek. +- Planning Gate result: Passed. + +## Outcome + +- Added a closed parent-only `makelore_web_search` model tool. Its registry reads the + frozen selected-model capability, keeps the same provider/credential/model, emits the + verified provider-native forced-search request shape, and never falls back to + `agent_browser`, a second model, the removed hosted client, or Plugin Charges. +- Added a Main-owned Device Package deep module with conversation tools for + inspect/preview, confirm/install, list, enable/disable, and uninstall. It accepts npm, + Git, absolute local Plugin directories, and loose `SKILL.md` sources; lifecycle scripts + are disabled and executable extensions require the explicit desktop-authority warning. +- Device packages commit as immutable generations, default enabled, and automatically + refresh new/idle parent workers. Active workers retain the frozen generation until + settlement, and child workers receive no Device Package resources. +- My Plugins now separates Official Plugins and Device Installed. It exposes no visible + installer/source picker. Device state does not enter Account Library, Release, Channel, + Admission, Marketplace Package Store, project enablement, or Agent assignment. +- Removed the old Hosted Web Search package definition, Main client/adapter, conversation + envelope cases, artifact authority, and associated tests while preserving Game Resource + and generic Marketplace behavior. + +## Verification + +- TDD and focused model-tool/device-package/resource/route/Renderer tests passed; final + focused Device Package slice: 4 files / 15 tests. +- Full unit suite: 218 files, 1,786 passed, 2 skipped; pressure test 1 passed. One initial + run saw two unchanged real-Pi `spawn EBUSY` failures; the isolated retry passed 3/3 and + the full suite then passed. +- `pnpm typecheck` passed. Full lint passed with 0 errors and 5 unchanged warnings in + Home/Makelore. +- Vite Renderer/Main/Preload/utility production build and Pi win32-x64 runtime staging + passed; Renderer transformed 2,275 modules and the staged Pi closure contains 130 + packages / 6 assets. +- Windows Electron lifecycle suite passed 6/6 on clean retry. The first run's six tests + passed but Electron returned a transient Windows access-violation exit code afterward. +- `git diff --check` passed. +- Exact committed-head Windows packaging passed from + `0c54d874cf3d21f6694b9a246e3d11b68817234a`. The first aggregate wrapper reached + the external GitHub `uv` download and failed with `ECONNRESET`; the already verified + local `uv 0.10.0` binaries were then reused, after which staging, electron-builder, + NSIS signing, `verify:artifact:win`, and `verify:artifact:pi` all passed. +- The installer is 208,279,484 bytes with SHA-256 + `7F9AA7DC598256673D13C1FA103C1BEE173691F4A329B6F73BCBDA34CFF6C335`. + Artifact metadata records both `gitCommit` and `verificationHead` as the exact + committed head. The packaged Plugin inventory is exactly Data Service + Game + Resource; selected-model Web Search and Device Package authority are both proven + reachable from `app.asar`, with no Hosted Web Search Provider authority. + +## Follow-ups + +- HOLD: packaged signed-in conversation acceptance for one loose Skill, one fixture Pi + extension, and a live `npm:pi-web-search` install with network access. +- HOLD: real selected-model Web Search through the deployed AI Gateway requires explicit + paid-Provider authorization and a supported credential/model. +- Independent fixed-range Standards and Spec reviews remain the next gate after the + paired commits are integrated. + +## Promotion Candidates + +- Keep the model-tools/device-packages proposal pending until packaged install and live + model-search acceptance satisfy its promotion criteria. diff --git a/.project-docs/30-worklog/tasks/20260902-web-search-client-integration-a4c8e291.md b/.project-docs/30-worklog/tasks/20260902-web-search-client-integration-a4c8e291.md new file mode 100644 index 0000000..d259f4d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-web-search-client-integration-a4c8e291.md @@ -0,0 +1,100 @@ +# Task: Integrate Web Search unavailable-release diagnostics + +## Identity + +- Task ID: 20260902-web-search-client-integration-a4c8e291 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 2c5b60ea78e014a712a47bdbb9efd156f52f3a1d +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Integrate exact source commit `2e093bd22c0a3c46cc08b8eede8c1bc02afd690b` + from completed feature task `20260901-web-search-runtime-stale-r2-6f4a2d91`. +- Preserve the two pre-existing untracked package task records byte-for-byte and keep + all other client product, package, installed application, and runtime state unchanged. +- Record the integrated diagnostic boundary in canonical current state and run the + source-focused verification plus Integration Documentation Gate. + +## Intent And Constraints + +- The source commit changes only the mapping of an explicit Marketplace + `action='unavailable'`: it must surface `plugin_release_unavailable`, not the + unrelated `plugin_runtime_stale` snapshot error. +- Do not weaken frozen worker, exact Release, Account/project, policy, Admission, + Provider, confirmation, or billing checks. Do not install/package/restart the running + application, mutate Account/Library/project state, deploy, publish, or invoke a paid + Provider. +- User authorization to take over the client root while preserving unowned files was + applied through `--adopt-existing`; the exact pre-existing files remain outside this + task's ownership: + `.project-docs/30-worklog/tasks/20260901-package-122-c5e8.md` and + `.project-docs/30-worklog/tasks/20260901-package-123-d7f3.md`. +- Concurrent Task Gate: PASS. Integration lock and exact root identity are held for + task `20260902-web-search-client-integration-a4c8e291` on `main` at base + `2c5b60ea78e014a712a47bdbb9efd156f52f3a1d`; no other task owns this worktree. + +## Project Context Loaded + +- Project goal: Electron Main remains the authenticated Marketplace and hosted-Plugin + authority; Renderer/Pi receive only closed projections from an exact frozen parent + worker. +- Current integrated focus: official bundled Game Resource/Web Search already use + account acquisition plus server Release Admission without device package download. +- Active task scope: integrate one reviewed diagnostic correction and its two-reason + regression, then update canonical integrated state. +- Active constraints: no silent state advancement; no hot swap; no child inheritance; + no client-side bypass of Release, Provider, or Token Point authority. +- Decisions affecting this task: ADR-006 single Pi runtime and logical-thread resource + ownership; Marketplace Library, project selection, Agent assignment, Admission, and + billing remain distinct states. +- Evidence/reflections/commitments: the installed `1.2.4` artifact and fresh Pi manifest + proved correct tool materialization; live public Marketplace detail proved the server + Stable Release was absent. Paid Provider and production deployment remain external. +- Files involved: hosted Admission resolver, its focused test, source task record, + this integration record, and canonical current state only. +- Other active local tasks: many historical isolated feature/review owners remain in + the registry, but none owns this root or the changed hosted-Admission files at the + current frontier. The completed source feature is the only semantic input. +- Unknowns/conflicts: current production Web Search Provider Config/model is not + revalidated by this client integration; no semantic conflict or promotion decision + is present. +- Gate result: Passed. + +## Outcome + +- Cherry-picked the exact completed source as product commit + `93e3143e8a427829efc1894317f188868c21803c` on local `main`; source and integrated + product trees match for the resolver/test/source-record change. +- Canonical current state now records the corrected unavailable-Release diagnostic and + keeps the server migration/Provider readiness requirements explicit. +- The feature task record remains on its isolated source branch and was removed from + the root integration history checkpoint, as required by the Integration Gate. +- The two pre-existing untracked package records remain present and unmodified. No + installed application, project, Account Library, server, package, or external state + was changed. + +## Verification + +- Source evidence adopted: focused 3 files / 25 tests, TypeScript typecheck, scoped + ESLint, diff gate, and feature task documentation gate passed. +- Post-integration rerun on exact `main`: the same 3 files / 25 tests passed; + `pnpm exec tsc --noEmit`, scoped ESLint, and `git diff --check` passed. +- Integration task-aware document drift passed, and `task_context complete` returned + `READY_FOR_INTEGRATION`. + +## Follow-ups + +- Deploy Works Square commits containing migrations `0078` and `0079`, run Alembic to + exact `20260901_official_plugin_compat_0079`, and verify Web Search public detail has + Stable Release `00000000-0000-4000-8000-000000000204`. +- Revalidate the server-owned Web Search Provider Config/model and deployment secret, + then reopen the worker. A rebuilt client is needed only to display the improved typed + error; reinstalling cannot replace the missing server Release migration. + +## Promotion Candidates + +- None recorded. diff --git a/.project-docs/30-worklog/tasks/20260902-windows-titlebar-integration-afc9e259.md b/.project-docs/30-worklog/tasks/20260902-windows-titlebar-integration-afc9e259.md new file mode 100644 index 0000000..ec8f18a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-windows-titlebar-integration-afc9e259.md @@ -0,0 +1,63 @@ +# Task: Integrate Windows titlebar logo fix + +## Identity + +- Task ID: 20260902-windows-titlebar-integration-afc9e259 +- Mode: Integration +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: d83757a115b12a68ea69d5acfb4a0e8b5783216c +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Integrate source commit `99a210e` from task `20260901-windows-titlebar-logo-overlap-5100e298` onto local `main`. +- Re-run proportionate verification against the current integrated tree. +- Record the integration in canonical current state without promoting new architecture, domain, or product-direction claims. + +## Intent And Constraints + +- Preserve the source change exactly unless the current `main` requires an explicit, reviewed conflict resolution. +- Keep the fix limited to Windows titlebar control spacing and its focused regression coverage; macOS/Linux behavior remains unchanged. +- Do not push, deploy, package, publish, or alter application identity, protocols, backend contracts, or unrelated layout. +- Preserve the pre-existing untracked package task records `20260901-package-122-c5e8.md` and `20260901-package-123-d7f3.md` byte-for-byte and exclude them from all commits. +- Treat the source task record and its supporting evidence as read-only integration inputs. + +## Project Context Loaded + +- Project goal: preserve Makelore's four-module cross-platform Electron product and single light visual system. +- Current integrated focus: local `main` already contains the latest Web Search correction; this task is an independent Renderer-shell integration. +- Active task scope: integrate one completed Windows titlebar spacing correction, verify it on the current default branch, and record the exact integration frontier. +- Active constraints: no macOS/Linux behavior change, no unrelated layout or contract change, and no push, deployment, packaging, or publication. +- Decisions affecting this task: no accepted ADR or product-direction change is required; the Renderer/Main ownership boundary remains unchanged. +- Evidence, reflections, or commitments: the source task contains a deterministic pre-fix Electron reproduction and complete local verification; no reflection or future commitment is introduced. +- Files involved: shared TitleBar component, focused unit/Electron tests, source task record, this integration record, and canonical current state. +- Other active local tasks: all 207 peer task records were readable; only the completed source task shares titlebar semantics. +- Unknowns or conflicts: `project-positioning.md` remains a template placeholder, but AGENTS guidance, system overview, and current state provide the applicable product boundary. No unresolved semantic conflict exists. +- Gate result: Passed. + +## Outcome + +- Cherry-picked source commit `99a210e244731d1cdc923e9dd4adf6e09e64c2a4` without conflict as product commit `e800d42595484389432fd08b11c2202074a7009e` on local `main`. +- The shared Windows TitleBar now reserves the same 148 px region for both the custom controls and the Logo inset, eliminating the Canvas and Coding overlap while retaining the existing macOS/Linux behavior. +- Canonical current state records the exact source and product commits; no architecture, ADR, domain rule, backend contract, application identity, package, deployment, or publication changed. +- The two pre-existing untracked package task records remain outside this task and outside all commits. + +## Verification + +- Source evidence adopted: deterministic pre-fix Electron reproduction; focused 11/11; 214 files / 1,759 tests plus pressure; typecheck; scoped/full lint; Vite build; and two focused Electron E2E checks passed. +- Post-integration focused unit: `tests/unit/title-bar.test.tsx` passed 11/11. +- Post-integration `pnpm run typecheck` and scoped ESLint passed. +- Post-integration `pnpm run build:vite` passed; existing Browserslist, mixed dynamic/static import, and chunk-size warnings remain non-blocking. +- Post-integration Windows Canvas and Coding Electron titlebar E2E checks passed 1/1 each. +- Post-integration `pnpm run lint:check` passed with 0 errors and the same 5 pre-existing warnings in untouched Home and Makelore pages. +- Post-integration `pnpm test` passed 215 files / 1,788 tests with 2 skipped; pressure suite passed 1/1. + +## Follow-ups + +- None. + +## Promotion Candidates + +- None. This integration records a local Renderer layout correction and does not alter canonical architecture, domain rules, or product direction. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index b9630a2..764a531 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -19,6 +19,11 @@ - System-included Data Service ships with MakeLore and has no Library acquisition, Admission, download, update, or device-uninstall action. Users may still enable it per project and assign its Skill to an Agent. +- Code-owned Game Resource is the optional bundled hosted Plugin. Its exact schema-2 + manifest, Skill, and tools ship with MakeLore, so it has no device download, update, + Beta, signature, or device-uninstall state. Account Library acquisition/removal, + project enablement, Agent assignment, current server policy, immutable Admission, + explicit confirmation, and Token Point billing remain distinct. - Production Marketplace trust fails closed while the official Ed25519 public key is absent. Test-only/integration keys and packaged unknown-key rejection are evidence, not authority to activate production. Generic `platform_hosted` client execution is @@ -38,6 +43,22 @@ not expose Provider credentials, URLs, credit balances, raw responses, or Provider job IDs. Saving a hosted result must use bounded project-relative paths and the existing project write lease. +- Native Web Search is a selected-model capability, not a Marketplace Plugin. Only an + exact verified capability may place `makelore_web_search` in a frozen parent worker; + it uses that worker's current model/provider/credential and ordinary model billing. + Unsupported models expose no tool, child workers receive none, and the implementation + must not switch models, call the retired hosted client, create a Plugin Charge, or + fall back to `agent_browser`. +- Device Packages are local Main-owned installations created only through conversation. + Inspect/preview and a distinct later confirmation precede commit; there is no visible + install/source-picker UI. Accepted npm, Git, absolute local Plugin-directory, and loose + `SKILL.md` sources become immutable generations and default enabled. Lifecycle scripts + never run. Pi extensions execute with desktop-user authority after that fact is shown + in confirmation. New and idle parent workers refresh automatically, active parents + switch only after settlement, and child workers never inherit Device Package resources. +- Device Packages appear separately from Official Plugins in My Plugins and never enter + Account Library, Marketplace Package Store, Release, Channel, Admission, project + enablement, Agent assignment, or server billing state. - 面向用户的 AI 编程新建流程必须在 `mini_game`、`mini_program`、`custom` 中选择;`ProjectType` 是产品类型,创建后不能通过 UI 或 Host API 修改,未传类型的兼容 API 调用按 `custom` 处理。 - 新建小游戏和小程序会生成平台固定版本的受控 Vite 发布模板,并可使用项目配置中的单一“提交审核”入口;`custom` 和缺少类型字段的旧项目不提供一键发布。 - `ProjectType` 不等于 `BuildPreset`:第一期两个可发布产品类型都映射到内部受控 Vite preset;本地 `projectType` 不是授权边界,Main-owned 安全打包、Host API 和服务端包体校验仍必须执行。 diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md index cbe0b03..1234d08 100644 --- a/.project-docs/50-evidence/evidence-index.md +++ b/.project-docs/50-evidence/evidence-index.md @@ -6,6 +6,7 @@ Use this index for searchable, traceable evidence records. |---|---|---|---|---| | 2026-09-02 | Makelore Code Pi write-leased tool-batch deadlock fix | Integrated locally; source and production-build verification passed; no installer produced | Source `49112b6`, merge `55bf80e`, source task `20260902-fix-bash-tool-bridge-9c4e7a12`, integration task `20260902-integrate-bash-tool-bridge-e81a5c6d` | Live evidence showed every assistant response emitting two Bash calls; Pi prepared both hooks before execution, so the first acquired the project write lease and the second waited before the first could run or release it. The five-minute delay was the managed-extension HTTP idle timeout, not a missing dependency. Built-in `bash`/`edit`/`write` and dynamically declared lease-requiring tools are now sequential within a batch while read-only batches remain parallel. The pre-fix real-process regression reproduced `fetch failed`; the source passed 3 focused files / 9 tests, 213 full files / 1,733 tests with 3 skipped, typecheck, lint with 0 errors / 5 existing warnings, and the Renderer/Main/Preload/utility production build. | | 2026-09-02 | Makelore Code missing terminal-settlement recovery | Integrated locally; live pre-fix stall diagnosed and automated regression coverage passed | Source `f1fd13a`, merge `e76a1e1`, source task `20260901-fix-session-settlement-a47d2e91`, integration task `20260902-integrate-session-settlement-6e41c8b2` | Live evidence showed Pi had persisted its final assistant response while Main retained run ownership because no effective `agent_settled` arrived. The fix preserves normal settlement authority and no-replay semantics, reports post-accept prompt rejection, and after a bounded grace period probes only the target thread: exact idle evidence hydrates and settles once; contradictory state fails that target explicitly. The source passed 68 focused tests, 1,732 full tests / 3 skipped, typecheck, lint, and Vite build; the integrated tree repeated focused runtime/process tests, typecheck, and Vite build. | +| 2026-09-01 | Makelore Code packaged Agent Server Pi AI resolver fix | Integrated on local `main`; rebuilt Windows artifact and installed-client acceptance pending | Source `7df245a`, source task `20260901-local-runtime-unavailable-8b42c7f1`, merge `9640255`, verified candidate `42ea83c`, main promotion task `20260901-promote-local-runtime-main-a7c4e291` | The installed Electron Node process reproduced `ERR_MODULE_NOT_FOUND` for the present `@earendil-works/pi-ai@0.84.2` because the optional `import.meta.resolve` parent URL was inactive and lookup began beside `resources/resources/pi-agent-server.mjs`. Enabling that capability only for the Main-owned Agent Server made the exact differential pass without changing the runtime closure. A production-shaped sibling-layout Electron test went red before and green after; 24 focused tests, 1,761 full unit tests / 2 conditional skips, typecheck, lint with 0 errors, and Renderer/Main/Preload/utility build passed. This does not claim that the currently installed old package is fixed or that a rebuilt final artifact has passed release proof. | | 2026-08-30 | MakeLore AI Design Living Form V2 hard cutover | Integrated locally with matching server source; production data cutover and Provider activation pending | Client source `b0b5a602b501308a23eb27e2f51a5169b9e46b1e`, server source `b5351d54f595ce8eb873593e462e4a556bea0b05`, integration task `20260830-integrate-marketplace-design-client-main-9d5f3b82`, ADR-007 | Replaces nested V1 Conversations, Briefs, editable provider Prompt, mutable Quote PATCH, and local semantic fallback with one current Direction, one Living Form/Specification authority, stable operations, immutable Quote confirmation, and Main-owned V2 transport. Client source passed typecheck, lint with zero errors, 1,425 unit tests, Vite/Electron build, and 2 Electron E2E tests. Server source passed 2,626 tests / 31 skipped under UTF-8 mode. No production database cutover, real paid Provider request, deployment, or publication occurred. | | 2026-08-30 | MakeLore curated Plugin Marketplace Release A | Integrated locally; XMA-01 live acceptance PASS; production trust activation HOLD | Source `40df677a31ff7651f962151eb84b925987781c03`, source task `20260828-plugin-marketplace-client-5f8b3d72`, integration task `20260830-integrate-plugin-marketplace-client-6e3b9d82` | R7 Standards/Spec passed with zero findings. XMA-01 passed 12/12 using real PostgreSQL, signed-in packaged MakeLore, Operations-published temporary-key packages, A/B account isolation, immutable install/update/freeze/rollback, project/Agent/Pi materialization, lifecycle invalidation, pricing-only ETag change, Data Service 10 tools/14 operations and zero Token Point transactions. Final Windows installer was 208,235,670 bytes with SHA-256 `738A2F6573C502281787F418EA666C6E62C287CA0EC13C6EB22369DAB0B3FFFA`. The official Ed25519 public key was not supplied, so the production trust store correctly remains fail closed; no deployment or production publication occurred. | | 2026-08-26 | Makelore Code Pi hard cutover、后台运行 ownership 与 Works user-context Provider 修复 | 本地 `main` 已集成;Windows 最终安装包与 packaged proof Pass;真实 Provider/macOS/native Linux 非 Pass | 集成交付 `48a9189`、实现 `a098266`、任务 `20260826-fix-pi-model-provider-8d4c2a71` / `20260826-integrate-pi-provider-fix-6e4c2a91`、`docs/pi-runtime-release-runbook.md` | OpenCode production runtime/package/plugin/fallback 已删除,Pi `0.84.2` 是唯一 runtime。未解析 Conversation 现在 validate→persist resolved model→prepare;exact Works `works square AI gateway did not return one-api user context` 会失效缓存 credential、以非重试 Provider-auth failure 结束且不自动 replay,`agent_end willRetry:false` 不再被 `agent_settled` 覆盖成 completed。聚焦 47/47、related 54/54、projector 12/12、181 files / 1541 full unit + pressure、typecheck、lint、Vite build、Windows Electron 4/4、publish/runtime/artifact/Pi/subagent/final packaged proof 通过。NSIS 208,162,564 bytes,SHA-256 `A02F79FCD3273FB0B013450492DC788B6E5ED7BBC1007468FB83BE949B8CB30D`,未签名;final app.asar SHA-256 `A8394D0F7324B27D2C95469ABD4E2A39C739EABCC6D8B41E1D65BA4B41FB9D39`。真实 Provider 是 Explicitly Waived / Accepted Risk 且 `realTurnVerified=false`;macOS x64/arm64 与 native non-WSL Linux 未验收。 | diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md index f78b90b..fc361f3 100644 --- a/.project-docs/80-commitments/commitments.md +++ b/.project-docs/80-commitments/commitments.md @@ -13,7 +13,7 @@ Track future-facing memory: promised follow-ups, unfinished loops, timed checks, | 2026-08-16 | 验收 Prompt Museum 与 Canvas 删除/重报价的真实服务端链路 | 发布包含 `26b52d7` Canvas 行为的安装包前 | 客户端/服务端集成 | Pending | 使用真实 Works 账号核对 Museum 列表/详情/分页/筛选/署名/CDN/Prompt 回填,并确认固定相对媒体路径可经 Main Bearer 代理、401 刷新、10 MiB/可信 raster MIME 边界后在 Renderer 展示;核对最终 Prompt/options 重报价和确认设计点;删除 Workspace 后确认软删除可见性、未提交任务取消/预留积分释放、已运行任务结算。保留 Main 错误脱敏和严格 DTO/HTTPS 投影,不以客户端回归替代服务端验收 | | 2026-08-16 | 验收 default-on Robot Guided Hotspot Binding 的 Windows/macOS 真实设备链路 | 下一份包含页面内热点连接行为的安装包发布前 | 客户端/硬件/服务端集成 | Pending | 核对精确出货固件与固定 Portal、六位码发行/消费语义;执行 Windows Robot 真机扫描/连接、签名 macOS x64/arm64 CoreLocation/CoreWLAN/worker/ASAR/Koffi smoke,以及真实 Host/native Electron 端到端配网+Binding;保留 `NIANCODE_AI_HARDWARE_GUIDED_HOTSPOT_BINDING=0` 回滚并记录支持矩阵 | | 2026-08-10 | 完成客户端提交到 App `play_url` 播放的真实生产整链验收 | source+built+contract 服务端协议、OSS immutable Release、CDN/Edge 与 App 消费链成组集成后 | 客户端/服务端集成 | Pending | 使用真实账号执行小游戏和小程序创建、客户端本地构建与同字节预检、双归档提交、服务端逐字节校验/不可变 Release 固化、运营批准、CDN 发布、App 播放与监控核对;如需不可绕过 runtime gate,另行设计可信 verifier | -| 2026-08-12 | Windows 发布流水线保留固定 npm/Pi 运行时产物门禁 | 每次生成正式 Windows 安装器时 | 客户端发布 | Pending | 运行 `pnpm verify:publish-runtime`、`pnpm verify:artifact:win`、`pnpm verify:artifact:pi` 与 final packaged proof。当前 208,162,564-byte / SHA-256 `A02F79FCD3273FB0B013450492DC788B6E5ED7BBC1007468FB83BE949B8CB30D` 安装器为本地未签名证据,尚未 push、发布或安装。 | +| 2026-08-12 | Windows 发布流水线保留固定 npm/Pi 运行时产物门禁 | 每次生成正式 Windows 安装器时 | 客户端发布 | Pending | 运行 `pnpm verify:publish-runtime`、`pnpm verify:artifact:win`、`pnpm verify:artifact:pi` 与 final packaged proof;启动最终 `resources/resources/pi-agent-server.mjs` 并确认它从相邻 `pi-runtime` 初始化,再用安装后的首个 Conversation 确认不出现 runtime-unavailable 投影。当前 208,162,564-byte / SHA-256 `A02F79FCD3273FB0B013450492DC788B6E5ED7BBC1007468FB83BE949B8CB30D` 安装器早于 `7df245a` 修复,为本地未签名历史证据,尚未 push、发布或安装。 | | 2026-08-10 | 删除客户端 `runtime_url` 兼容回退 | 一个客户端兼容版本结束,且服务端与存量数据稳定提供 `play_url` | 客户端 | Pending | 删除类型字段、读取分支和对应回归测试 | | 2026-08-11 | 部署并验收 AI 绘画多 Conversation 服务端契约 | 发布包含多会话客户端之前 | 客户端/服务端集成 | Pending | 确认迁移 `0033`、Conversation list/create/read/turn/confirm API、持久 Agent Session 与事件字段已上线 | | 2026-08-12 | 复核 AI 编程 Provider 错误与重试投影 | 升级 Pi、Works gateway 或上游 Provider 错误格式时 | 客户端/Pi/provider 集成 | Pending | 重新验证确定性 Works user-context 缺失仍会 expire credential、非重试 fail fast、固定脱敏为 `CODING_PROVIDER_AUTH_REQUIRED`,并且不会被后续 `agent_settled` 改写为 completed;不得把其他 401/429/5xx 扩大归类。 | diff --git a/README.md b/README.md index db1135c..a0a9d29 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Makelore 是一个面向软件、视觉创作、互动学习与智能机器人 - AI 学习:主区展示服务端分页项目卡片,详情页用安全 Markdown 渲染 README;原始 HTML 被禁用,Markdown 图片节点直接加载服务端校验后的无凭据 HTTPS URL,包括 SVG 和 Electron 支持的其他图片格式,不经过服务端下载、识别、转码或镜像。下载按钮打开系统保存对话框,Main 不按 `Content-Length`、声明字节数或客户端上限阻断下载,流式校验 SHA-256 与 ZIP 签名后原子保存。客户端不提供课程生成、课程播放器、本地课程库、Agent、ASR 或课堂 runtime。运营管理与接口字段见 [`docs/learning-project-catalog-server-contract.md`](docs/learning-project-catalog-server-contract.md)。 - 插件市场 Release A:侧栏不再提供独立插件入口,项目配置中的“插件服务”统一承载“发现插件”“我的插件”“项目启用”三个标签。它们分别管理 Marketplace 浏览获取,账号 Library 与本机安装/更新/删除设备包,以及当前项目启用与配置;“免费获取”只写入账号 Library,“下载/更新”才写入本机 Package Store,“启用到项目”和“分配给伙伴”仍是彼此独立的动作,不会因获取或下载自动推进。旧插件路由仅保留兼容跳转。 - 插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。下一代 Pi worker 使用同一个 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;`skill_only` 不依赖运行时 Policy,也不执行分发包中的任意代码。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。 +- 原生 Web Search 是 `platform_hosted` 精选插件:用户仍需依次免费获取、下载、启用到项目并分配给伙伴,每次搜索还必须显式确认 Token Point 消耗。Renderer 与插件包不持有 Provider 密钥、模型或 URL;Main 只调用固定 Works Square typed route,并把收费状态作为 closed receipt 展示。正式激活仍等待官方签名公钥、`WEB_SEARCH_OPENAI_API_KEY`、模型、价格与隐私文案;开发和验收不得把 fake Provider 结果当作生产 OpenAI 通过。 - 提示词博物馆:只陈列经过审核的作品预览、Prompt、分类以及作者/来源/许可证信息,支持搜索、使用场景/风格/主体筛选和详情抽屉;“使用此 Prompt”只把原文带回当前 Canvas 对话草稿,不自动发送、不构成社区。列表和详情数据由服务端提供,客户端不打包数据集;服务端字段契约见 [`docs/prompt-museum-server-contract.md`](docs/prompt-museum-server-contract.md)。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 - 字体系统:Renderer UI 内嵌 Inter Variable 与经过字符子集化的 Source Han Sans SC WOFF2,按字符范围统一中英文并保留系统中文字体 fallback;代码、路径和日志使用独立等宽字体。 @@ -113,6 +114,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 项目内置编码技能 - 产品内置四个核心编码技能根:`agent-browser`(开发浏览器)、`frontend-slides`(项目演示)、`grilling`(方案质询)和 `planning-with-files`(项目规划),统一从 vendor-neutral 的 `resources/coding-skills/` 打包。`data-service`(开发数据)由固定的 `resources/coding-plugins/data-service/` 插件包持有,不在核心技能根中复制路径或定义。 +- Marketplace 插件 Skill(包括 Web Search)从已签名 Package Store Release 动态物化,不进入核心技能根,也不靠静态工具白名单;只有下一代 parent worker 的冻结 effective snapshot 同时满足安装、账号 Library、项目启用与伙伴分配时才生效,child worker 不继承 hosted tool。 - 项目插件“启用”和把插件技能分配给智能体是两个独立动作:只有项目已启用 Data Service 时,未分配的 `data-service` 才可供新选择;禁用后,已有分配仍会显示并继续保存在智能体的 `skillIds`,但处于不可用且不生效的状态,重新启用后恢复生效。未启用的技能不进入该智能体的有效 Pi 资源集合。 - `data-service` 只在用户显式请求后触发:先检查并说明最小集合,用户确认后配置一次、复制 SDK 资产,再用本地预览执行 put/read-back;它不用于已发布作品。 - 创建项目智能体时,`agent-browser`、`grilling` 与 `planning-with-files` 默认启用;`frontend-slides` 作为专项能力可手动启用。用户可以在创建或编辑智能体时调整选择。最终选择写入项目智能体的 `skillIds`。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 542414c..56c5b02 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -71,11 +71,16 @@ import { type CodingProjectPluginService, type CodingPluginMarketplaceService, } from './coding-product-services'; +import { CODE_OWNED_OPTIONAL_BUNDLED_RELEASES } from '../../shared/coding-plugins'; +import { ModelToolRegistry } from '../coding-runtime/pi/model-tools/model-tool-registry'; +import { DevicePackageManager } from '../coding-packages/device-package-manager'; +import { DevicePackageTools } from '../coding-packages/device-package-tools'; export interface CodingCompositionPaths { executablePath: string; cliPath: string; serverPath: string; + npmCliPath?: string; userDataDir: string; bundledSkillsDir: string; } @@ -136,7 +141,7 @@ export function resolveCodingPiRuntimePaths(input: { resourcesPath: string; appPath: string; executablePath: string; -}): Pick { +}): Pick { return { executablePath: resolvePiWorkerExecutablePath(input.executablePath), cliPath: input.isPackaged @@ -152,6 +157,9 @@ export function resolveCodingPiRuntimePaths(input: { serverPath: input.isPackaged ? path.join(input.resourcesPath, 'resources', 'pi-agent-server.mjs') : path.join(input.appPath, 'resources', 'pi-agent-server.mjs'), + npmCliPath: input.isPackaged + ? path.join(input.resourcesPath, 'publish-runtime', 'bin', 'npm-cli.js') + : path.join(input.appPath, 'node_modules', 'npm', 'bin', 'npm-cli.js'), }; } @@ -207,14 +215,26 @@ export function createCodingComposition( }; }; let effectiveResolver: EffectivePluginResolver | undefined; + let invalidateDeviceResources = async (): Promise => undefined; const projectStore = options.projectStore ?? createCodingProjectStore(options.storage); const attachments = new CodingAttachmentStore( path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'), ); + const modelToolRegistry = new ModelToolRegistry(); + const devicePackageManager = new DevicePackageManager({ + rootDir: path.join(options.paths.userDataDir, 'coding-runtime', 'device-packages'), + executablePath: options.paths.executablePath, + cliPath: options.paths.cliPath, + ...(options.paths.npmCliPath ? { npmCliPath: options.paths.npmCliPath } : {}), + onGenerationChanged: async () => await invalidateDeviceResources(), + }); + const devicePackageTools = new DevicePackageTools(devicePackageManager); const productTools = new PiProductTools({ browser: options.browser, attachments, bundledSkillsDir: options.paths.bundledSkillsDir, + modelToolRegistry, + devicePackageTools, pluginSkillSources, getPluginSkillSources: async () => effectiveResolver ? (await effectiveResolver.getSkillSources()).map((source) => ({ @@ -287,6 +307,7 @@ export function createCodingComposition( marketplace: marketplaceClient, packageStore, makeloreVersion: options.clientVersion ?? '2.0.0', + bundledReleases: CODE_OWNED_OPTIONAL_BUNDLED_RELEASES, }); const policyClient = options.policyClient ?? new PluginPolicyClient(); const knownPluginIds = new Set(pluginDefinitions.map(({ id }) => id)); @@ -374,6 +395,9 @@ export function createCodingComposition( : {}), extensionHost, capabilityRegistry, + modelToolRegistry, + devicePackageManager, + devicePackageTools: devicePackageTools.tools, }), }); const childOpener = createPiManagedSubagentChildOpener({ @@ -431,7 +455,7 @@ export function createCodingComposition( }, }); const invalidateManagedResources = async (): Promise => { - runtime?.markResourcesStale(); + await runtime?.refreshResources(); const projectPaths = [...conversationStores.entries()]; for (const [, store] of projectPaths) { const conversationsInProject = await store.read() @@ -440,6 +464,7 @@ export function createCodingComposition( for (const conversation of conversationsInProject) registry.forget(conversation.id); } }; + invalidateDeviceResources = invalidateManagedResources; const pluginMarketplace: CodingPluginMarketplaceService = createCodingPluginMarketplaceService({ marketplace: marketplaceClient, packageStore, @@ -471,6 +496,7 @@ export function createCodingComposition( return { attachments, dataService, + devicePackages: devicePackageManager, plugins, pluginMarketplace, marketplace: pluginMarketplace, @@ -490,6 +516,7 @@ export function createCodingComposition( await Promise.allSettled(conversationIds.map((conversationId) => ( runtime.dispose(conversationId, reason) ))); + if (reason === 'background_sleep' && runtime.hasActiveWork()) return; await agentServer.stop(); }, async shutdown() { diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 55f50d8..a0ef6e9 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -47,6 +47,7 @@ import type { } from '../coding-plugins/package-store'; import type { MarketplaceLibrarySnapshot } from '../coding-plugins/account-plugin-cache'; import type { EffectivePluginResolver } from '../coding-plugins/effective-resolver'; +import type { DevicePackageManager } from '../coding-packages/device-package-manager'; export interface ActiveCodingProject { id: string; @@ -66,6 +67,7 @@ export interface CodingProductHost { export interface CodingProductComposition { attachments: CodingAttachmentStore; dataService: DataServiceOperations; + devicePackages: DevicePackageManager; previewDataSession?: PreviewDataSessionManager; productTools: PiProductTools; pluginMarketplace: CodingPluginMarketplaceService; @@ -674,7 +676,7 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi async listSkills(agentId) { const project = await activeProject(); const assignedSkillIds = await selectedSkillIds(project.path, agentId); - const effective = options.effectiveResolver + const effective = agentId && options.effectiveResolver ? await options.effectiveResolver.resolve({ projectId: project.id, projectPath: project.path, @@ -687,11 +689,17 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi // still supplies the availability set; only the worker opener receives // the effective subset. const skillIds = assignedSkillIds; - const enabledPluginIds = effective - ? effective.effectiveSkillIds - : options.getEnabledPluginIds - ? await options.getEnabledPluginIds(project.path) - : []; + // The unscoped list is the Agent assignment catalog. It must expose + // every project-enabled plugin Skill before that Skill has been assigned; + // the resolver's effective subset is only authoritative once an Agent is + // selected (and for worker-facing projections). + const enabledPluginIds = !agentId && options.getEnabledPluginIds + ? await options.getEnabledPluginIds(project.path) + : effective + ? effective.effectiveSkillIds + : options.getEnabledPluginIds + ? await options.getEnabledPluginIds(project.path) + : []; return await options.productTools.listSkills( skillIds, enabledPluginIds, diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 2e03b70..4168cbb 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -22,6 +22,7 @@ import { handleCodingProjectRoutes } from './routes/coding-projects'; import { handleCodingConversationRoutes } from './routes/coding-conversations'; import { handleCodingPluginRoutes } from './routes/coding-plugins'; import { handlePluginMarketplaceRoutes } from './routes/plugin-marketplace'; +import { handleDevicePackageRoutes } from './routes/device-packages'; export type HostApiRouteHandler = ( req: IncomingMessage, @@ -51,6 +52,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleCodingAttachmentRoutes, handleCodingProjectRoutes, handlePluginMarketplaceRoutes, + handleDevicePackageRoutes, handleCodingPluginRoutes, handleCodingConversationRoutes, handleCodingFileRoutes, diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts index 499a904..8666664 100644 --- a/electron/api/routes/coding-conversations.ts +++ b/electron/api/routes/coding-conversations.ts @@ -12,7 +12,7 @@ import { } from '../route-utils'; import { decodeRouteId, sendCodingRouteError } from './coding-route-errors'; -const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high']); +const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'max']); function invalidRequest(message: string): never { throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message); @@ -187,7 +187,7 @@ export async function handleCodingConversationRoutes( sendJson(res, 200, { model: await service.setThinking( conversationId, - body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high', + body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max', ), }); return true; diff --git a/electron/api/routes/device-packages.ts b/electron/api/routes/device-packages.ts new file mode 100644 index 0000000..aef863c --- /dev/null +++ b/electron/api/routes/device-packages.ts @@ -0,0 +1,112 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { DevicePackageError } from '../../coding-packages/device-package-manager'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +const ROOT = '/api/coding/device-packages'; +const PACKAGE = /^\/api\/coding\/device-packages\/([^/]+)$/u; +const PACKAGE_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u; + +class DevicePackageRouteError extends Error { + constructor(readonly status: 400 | 404, message: string) { + super(message); + } +} + +function packageId(value: string): string { + try { + const decoded = decodeURIComponent(value); + if (PACKAGE_ID.test(decoded)) return decoded; + } catch { + // Project a single bounded request error below. + } + throw new DevicePackageRouteError(400, 'Device package id is invalid'); +} + +function noQuery(url: URL): void { + if (url.search) throw new DevicePackageRouteError(400, 'Query parameters are not supported'); +} + +async function exactBody(req: IncomingMessage, keys: readonly string[]): Promise> { + const body = await parseJsonBody(req); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new DevicePackageRouteError(400, 'Request body is invalid'); + } + const record = body as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new DevicePackageRouteError(400, 'Request body has unexpected fields'); + } + return record; +} + +function sendError(res: ServerResponse, error: unknown): void { + if (error instanceof DevicePackageRouteError) { + sendJson(res, error.status, { + success: false, code: 'local_package_request_invalid', error: error.message, + }); + return; + } + if (error instanceof DevicePackageError) { + const status = error.code === 'local_package_not_installed' || error.code === 'local_package_not_found' + ? 404 + : error.code === 'local_package_in_use' || error.code === 'local_package_confirmation_required' + ? 409 + : error.code === 'local_package_dependency_failed' || error.code === 'local_package_install_failed' + ? 503 + : 422; + sendJson(res, status, { success: false, code: error.code, error: error.message }); + return; + } + if (error instanceof SyntaxError) { + sendJson(res, 400, { + success: false, code: 'local_package_request_invalid', error: 'Request body is invalid', + }); + return; + } + sendJson(res, 503, { + success: false, code: 'local_package_install_failed', error: 'Device package service is unavailable', + }); +} + +export async function handleDevicePackageRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + const match = PACKAGE.exec(url.pathname); + const known = (url.pathname === ROOT && req.method === 'GET') + || Boolean(match && (req.method === 'PATCH' || req.method === 'DELETE')); + if (!known) return false; + const manager = ctx.codingProducts?.devicePackages; + if (!manager) { + sendJson(res, 503, { + success: false, code: 'local_package_install_failed', error: 'Device package service is unavailable', + }); + return true; + } + try { + noQuery(url); + if (url.pathname === ROOT) { + sendJson(res, 200, await manager.list()); + return true; + } + const id = packageId(match?.[1] ?? ''); + if (req.method === 'PATCH') { + const body = await exactBody(req, ['enabled']); + if (typeof body.enabled !== 'boolean') { + throw new DevicePackageRouteError(400, 'enabled is invalid'); + } + sendJson(res, 200, await manager.setEnabled(id, body.enabled)); + return true; + } + await exactBody(req, []); + sendJson(res, 200, await manager.uninstall(id)); + return true; + } catch (error) { + sendError(res, error); + return true; + } +} diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 62cec1d..e5d5050 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -20,8 +20,10 @@ import { seedWorksSquareAIGatewayCredential } from '../../services/works-square- import { NIANCODE_USER_MODEL_ACCOUNT_ID, NIANCODE_USER_MODEL_ACCOUNT_LABEL, + normalizeImportedModelCapabilities, normalizeImportedUserModelId, } from '../../../shared/user-model-config'; +import type { ImportedModelCapabilities } from '../../../shared/imported-model-profile'; const legacyProviderRoutesWarned = new Set(); @@ -141,6 +143,7 @@ type ImportedUserModelConfig = { credentialMode: string; apiKeyExpiresIn: number | null; models: string[]; + modelCapabilities?: ImportedModelCapabilities; }; const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; @@ -157,7 +160,7 @@ class WorksSquareModelConfigError extends Error { } } -function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelConfig { +export function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelConfig { if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { throw new Error('Works Square model config response is invalid'); } @@ -181,6 +184,7 @@ function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelCo const label = typeof record.label === 'string' && record.label.trim() ? record.label.trim() : NIANCODE_USER_MODEL_ACCOUNT_LABEL; + const modelCapabilities = normalizeImportedModelCapabilities(record.model_capabilities, models); return { label, @@ -189,6 +193,7 @@ function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelCo credentialMode: credentialMode || 'api_key', apiKeyExpiresIn, models, + ...(modelCapabilities ? { modelCapabilities } : {}), }; } @@ -211,9 +216,13 @@ function importedUserModelMetadata( if (useLocalAiProxy) { const metadata = { ...(existing?.metadata ?? {}) }; delete metadata.worksSquareCredentialExpiresAt; + delete metadata.worksSquareModelCapabilities; return { ...metadata, customModels: modelConfig.models, + ...(modelConfig.modelCapabilities + ? { worksSquareModelCapabilities: modelConfig.modelCapabilities } + : {}), worksSquareCredentialMode: WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE, worksSquareOneApiBaseUrl: modelConfig.baseUrl, }; @@ -222,12 +231,16 @@ function importedUserModelMetadata( const metadata = { ...(existing?.metadata ?? {}) }; delete metadata.worksSquareCredentialExpiresAt; delete metadata.worksSquareOneApiBaseUrl; + delete metadata.worksSquareModelCapabilities; const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null ? undefined : new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString(); return { ...metadata, customModels: modelConfig.models, + ...(modelConfig.modelCapabilities + ? { worksSquareModelCapabilities: modelConfig.modelCapabilities } + : {}), worksSquareCredentialMode: modelConfig.credentialMode, ...(credentialExpiresAt ? { worksSquareCredentialExpiresAt: credentialExpiresAt } : {}), }; @@ -295,6 +308,7 @@ function providerAccountRuntimeShape(account: ProviderAccount): unknown { isDefault: account.isDefault, metadata: { customModels: account.metadata?.customModels, + worksSquareModelCapabilities: account.metadata?.worksSquareModelCapabilities, worksSquareCredentialMode: account.metadata?.worksSquareCredentialMode, worksSquareOneApiBaseUrl: account.metadata?.worksSquareOneApiBaseUrl, }, diff --git a/electron/coding-packages/device-package-format.ts b/electron/coding-packages/device-package-format.ts new file mode 100644 index 0000000..7475001 --- /dev/null +++ b/electron/coding-packages/device-package-format.ts @@ -0,0 +1,224 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import type { + DevicePackageKind, + DevicePackageSkillEntry, +} from '../../shared/device-packages'; + +export type { + DevicePackageIndexV1, + DevicePackageKind, + DevicePackageRecordV1, + DevicePackageSkillEntry, + InstallPreviewV1, +} from '../../shared/device-packages'; + +export type DevicePackageSource = + | Readonly<{ kind: 'npm'; spec: string }> + | Readonly<{ kind: 'git'; spec: string }> + | Readonly<{ kind: 'file'; absolutePath: string }>; + +export interface InspectedDevicePackage { + packageId: string; + displayName: string; + resolvedVersion: string | null; + skillEntries: DevicePackageSkillEntry[]; + extensionEntries: string[]; + ignoredLifecycleScripts: string[]; +} + +const PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/u; +const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const LIFECYCLE_SCRIPT_NAMES = Object.freeze([ + 'preinstall', + 'install', + 'postinstall', + 'prepare', +] as const); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function nonempty(value: unknown, maximum: number): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized && normalized.length <= maximum ? normalized : null; +} + +function containsControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +export function parseDevicePackageSource(value: string): DevicePackageSource { + const source = nonempty(value, 2048); + if (!source || containsControlCharacter(source)) { + throw new Error('Device package source is invalid'); + } + if (source.startsWith('npm:')) { + const spec = nonempty(source.slice(4), 1024); + if (!spec || /\s/u.test(spec)) throw new Error('npm package source is invalid'); + return { kind: 'npm', spec: `npm:${spec}` }; + } + if (source.startsWith('git:') || /^https?:\/\//iu.test(source) + || /^ssh:\/\//iu.test(source) || /^git@[^:]+:/u.test(source)) { + if (/\s/u.test(source)) throw new Error('Git package source is invalid'); + return { kind: 'git', spec: source }; + } + if (!path.isAbsolute(source)) throw new Error('Local package source must be an absolute path'); + return { kind: 'file', absolutePath: path.resolve(source) }; +} + +export function safeDevicePackageId(value: string): string { + const normalized = value.trim().toLowerCase() + .replace(/^@/u, '') + .replaceAll('/', '.') + .replace(/[^a-z0-9._-]+/gu, '-') + .replace(/^[^a-z0-9]+|[^a-z0-9._-]+$/gu, '') + .slice(0, 128); + if (!PACKAGE_ID_PATTERN.test(normalized)) throw new Error('Device package id is invalid'); + return normalized; +} + +export function safeStorageSegment(value: string): string { + const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/gu, '-').slice(0, 128); + if (!normalized || normalized === '.' || normalized === '..') { + throw new Error('Device package version is invalid'); + } + return normalized; +} + +export function relativeContainedPath(root: string, candidate: string): string { + const resolvedRoot = path.resolve(root); + const resolvedCandidate = path.resolve(candidate); + const relative = path.relative(resolvedRoot, resolvedCandidate); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error('Device package entry escapes its package root'); + } + return relative.split(path.sep).join('/'); +} + +async function readJson(filePath: string): Promise | null> { + try { + const value = JSON.parse(await readFile(filePath, 'utf8')) as unknown; + return isRecord(value) ? value : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function existsFile(filePath: string): Promise { + return await stat(filePath).then((value) => value.isFile()).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false; + throw error; + }); +} + +async function skillId(skillPath: string): Promise { + const source = await readFile(skillPath, 'utf8'); + const frontmatter = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(source)?.[1] ?? ''; + const declared = /^name:\s*['"]?([^'"\r\n]+?)['"]?\s*$/imu.exec(frontmatter)?.[1]?.trim(); + const fallback = path.basename(path.dirname(skillPath)); + const id = declared || fallback; + if (!SKILL_ID_PATTERN.test(id)) throw new Error(`Skill id is invalid: ${id}`); + return id; +} + +async function collectSkillFiles(root: string): Promise { + const result: string[] = []; + const visit = async (directory: string): Promise => { + if (result.length > 100) throw new Error('Device package contains too many Skills'); + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(candidate); + else if (entry.isFile() && entry.name === 'SKILL.md') result.push(candidate); + } + }; + await visit(root); + return result; +} + +function manifestEntries(value: unknown, field: string): string[] { + if (value === undefined) return []; + const values = typeof value === 'string' ? [value] : value; + if (!Array.isArray(values) || values.length > 100 + || values.some((entry) => typeof entry !== 'string' || !entry.trim())) { + throw new Error(`${field} is invalid`); + } + return values.map((entry) => (entry as string).trim()); +} + +async function expandSkillManifestEntry(packageRoot: string, entry: string): Promise { + const candidate = path.resolve(packageRoot, entry); + relativeContainedPath(packageRoot, candidate); + const metadata = await stat(candidate).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) throw new Error(`Skill entry does not exist: ${entry}`); + if (metadata.isFile()) { + if (path.basename(candidate) !== 'SKILL.md') throw new Error(`Skill entry is not SKILL.md: ${entry}`); + return [candidate]; + } + if (!metadata.isDirectory()) throw new Error(`Skill entry is invalid: ${entry}`); + return await collectSkillFiles(candidate); +} + +export async function inspectDevicePackage( + packageRoot: string, + fallbackName = path.basename(path.resolve(packageRoot)), +): Promise { + const root = path.resolve(packageRoot); + const packageJson = await readJson(path.join(root, 'package.json')); + const codexManifest = await readJson(path.join(root, '.codex-plugin', 'plugin.json')); + const pi = isRecord(packageJson?.pi) ? packageJson.pi : null; + const declaredExtensions = manifestEntries(pi?.extensions, 'pi.extensions'); + const declaredSkills = manifestEntries(pi?.skills, 'pi.skills'); + const extensionFiles: string[] = []; + for (const entry of declaredExtensions) { + const candidate = path.resolve(root, entry); + relativeContainedPath(root, candidate); + if (!await existsFile(candidate) || !/\.(?:js|ts)$/iu.test(candidate)) { + throw new Error(`Extension entry is invalid: ${entry}`); + } + extensionFiles.push(candidate); + } + const skillFiles: string[] = []; + for (const entry of declaredSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry)); + const codexSkills = manifestEntries(codexManifest?.skills, 'plugin.skills'); + for (const entry of codexSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry)); + if (await existsFile(path.join(root, 'SKILL.md'))) skillFiles.push(path.join(root, 'SKILL.md')); + + const uniqueSkills = new Map(); + for (const skillPath of [...new Set(skillFiles.map((entry) => path.resolve(entry)))]) { + const id = await skillId(skillPath); + if (uniqueSkills.has(id)) throw new Error(`Duplicate Skill id: ${id}`); + uniqueSkills.set(id, { id, entryPath: relativeContainedPath(root, skillPath) }); + } + const extensionEntries = [...new Set(extensionFiles.map((entry) => relativeContainedPath(root, entry)))]; + if (uniqueSkills.size === 0 && extensionEntries.length === 0) { + throw new Error('Package does not expose a Pi extension or Skill'); + } + const displayName = nonempty(packageJson?.name, 256) + ?? nonempty(codexManifest?.name, 256) + ?? fallbackName; + const scripts = isRecord(packageJson?.scripts) ? packageJson.scripts : {}; + return { + packageId: safeDevicePackageId(displayName), + displayName, + resolvedVersion: nonempty(packageJson?.version, 128) ?? nonempty(codexManifest?.version, 128), + skillEntries: [...uniqueSkills.values()].sort((left, right) => left.id.localeCompare(right.id)), + extensionEntries: extensionEntries.sort(), + ignoredLifecycleScripts: LIFECYCLE_SCRIPT_NAMES.filter((name) => typeof scripts[name] === 'string'), + }; +} + +export function devicePackageKind(input: Pick): DevicePackageKind { + if (input.skillEntries.length > 0 && input.extensionEntries.length > 0) return 'mixed'; + return input.extensionEntries.length > 0 ? 'pi-extension' : 'skill-only'; +} diff --git a/electron/coding-packages/device-package-manager.ts b/electron/coding-packages/device-package-manager.ts new file mode 100644 index 0000000..477bcec --- /dev/null +++ b/electron/coding-packages/device-package-manager.ts @@ -0,0 +1,611 @@ +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { cp, mkdir, readFile, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { atomicWriteJson } from '../coding-projects/atomic-json'; +import { + devicePackageKind, + inspectDevicePackage, + parseDevicePackageSource, + relativeContainedPath, + safeStorageSegment, + type DevicePackageIndexV1, + type DevicePackageRecordV1, + type InstallPreviewV1, +} from './device-package-format'; + +export * from './device-package-format'; + +const PLAN_TTL_MS = 10 * 60 * 1000; +const INSTALL_OUTPUT_BYTES = 64 * 1024; +const INSTALL_TIMEOUT_MS = 2 * 60 * 1000; +const INDEX_SCHEMA_VERSION = 1; +const EXECUTABLE_WARNING = '此包包含可执行 Pi extension,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。'; + +export class DevicePackageError extends Error { + constructor( + readonly code: + | 'local_package_source_unsupported' + | 'local_package_not_found' + | 'local_package_manifest_invalid' + | 'local_package_dependency_failed' + | 'local_package_confirmation_required' + | 'local_package_plan_expired' + | 'local_package_install_failed' + | 'local_package_not_installed' + | 'local_package_in_use', + message: string, + ) { + super(message); + this.name = 'DevicePackageError'; + } +} + +export interface DevicePackageInstallInput { + source: string; + executablePath: string; + cliPath: string; + npmCliPath?: string; + agentDir: string; + cwd: string; + env: NodeJS.ProcessEnv; +} + +export type DevicePackageInstallRunner = (input: DevicePackageInstallInput) => Promise; + +export interface DevicePackageManagerOptions { + rootDir: string; + executablePath?: string; + cliPath?: string; + npmCliPath?: string; + runInstall?: DevicePackageInstallRunner; + now?: () => number; + createId?: () => string; + writeIndex?: (filePath: string, value: DevicePackageIndexV1) => Promise; + onGenerationChanged?(index: DevicePackageIndexV1): Promise | void; +} + +interface StagedPlanV1 { + schemaVersion: 1; + requestedByTurnId: string; + preview: InstallPreviewV1; + record: DevicePackageRecordV1; +} + +export interface EnabledDevicePackageResources { + generation: number; + packageIds: string[]; + packageRefs: Array<{ packageId: string; resolvedVersion: string }>; + skillEntries: Array<{ id: string; entryPath: string; packageRoot: string }>; + extensionPaths: string[]; +} + +function installEnvironment(agentDir: string): NodeJS.ProcessEnv { + return { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + PI_CODING_AGENT_DIR: agentDir, + PI_TELEMETRY: '0', + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', + CI: '1', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + npm_config_audit: 'false', + npm_config_fund: 'false', + }; +} + +export async function runPiPackageInstall(input: DevicePackageInstallInput): Promise { + await mkdir(input.agentDir, { recursive: true }); + await mkdir(input.cwd, { recursive: true }); + if (input.npmCliPath) { + await atomicWriteJson(path.join(input.agentDir, 'settings.json'), { + npmCommand: [input.executablePath, input.npmCliPath], + }); + } + await new Promise((resolve, reject) => { + execFile( + input.executablePath, + [input.cliPath, 'install', input.source, '--no-approve'], + { + cwd: input.cwd, + env: input.env, + windowsHide: true, + timeout: INSTALL_TIMEOUT_MS, + maxBuffer: INSTALL_OUTPUT_BYTES, + }, + (error) => error ? reject(error) : resolve(), + ); + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function string(value: unknown, field: string, maximum = 2048): string { + if (typeof value !== 'string' || !value.trim() || value.length > maximum) { + throw new DevicePackageError('local_package_install_failed', `${field} is invalid`); + } + return value; +} + +function parseRecord(value: unknown): DevicePackageRecordV1 { + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.source) + || !Array.isArray(value.skillEntries) || !Array.isArray(value.extensionEntries) + || typeof value.enabled !== 'boolean' || typeof value.confirmedExecutableCode !== 'boolean') { + throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); + } + const sourceKind = value.source.kind; + if (sourceKind !== 'npm' && sourceKind !== 'git' && sourceKind !== 'file') { + throw new DevicePackageError('local_package_install_failed', 'Device package source is invalid'); + } + const kind = value.kind; + if (kind !== 'skill-only' && kind !== 'pi-extension' && kind !== 'mixed') { + throw new DevicePackageError('local_package_install_failed', 'Device package kind is invalid'); + } + const skillEntries = value.skillEntries.map((entry) => { + if (!isRecord(entry)) throw new DevicePackageError('local_package_install_failed', 'Skill entry is invalid'); + return { id: string(entry.id, 'Skill id', 128), entryPath: string(entry.entryPath, 'Skill path') }; + }); + return { + schemaVersion: 1, + packageId: string(value.packageId, 'Package id', 128), + displayName: string(value.displayName, 'Display name', 256), + resolvedVersion: string(value.resolvedVersion, 'Resolved version', 128), + source: { + kind: sourceKind, + requested: string(value.source.requested, 'Requested source'), + resolved: string(value.source.resolved, 'Resolved source'), + }, + kind, + skillEntries, + extensionEntries: value.extensionEntries.map((entry) => string(entry, 'Extension path')), + enabled: value.enabled, + confirmedExecutableCode: value.confirmedExecutableCode, + installedAt: string(value.installedAt, 'Installed at', 64), + }; +} + +function parseIndex(value: unknown): DevicePackageIndexV1 { + if (!isRecord(value) || value.schemaVersion !== INDEX_SCHEMA_VERSION + || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 + || !Array.isArray(value.packages)) { + throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); + } + const packages = value.packages.map(parseRecord); + const ids = new Set(); + for (const record of packages) { + if (ids.has(record.packageId)) { + throw new DevicePackageError('local_package_install_failed', 'Device package index contains duplicate ids'); + } + ids.add(record.packageId); + } + return { schemaVersion: 1, generation: value.generation as number, packages }; +} + +function cloneIndex(index: DevicePackageIndexV1): DevicePackageIndexV1 { + return structuredClone(index); +} + +async function pathMetadata(target: string): Promise<'file' | 'directory' | null> { + return await stat(target).then((value) => value.isDirectory() ? 'directory' : value.isFile() ? 'file' : null) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); +} + +async function gitHead(directory: string): Promise { + return await new Promise((resolve, reject) => { + execFile('git', ['rev-parse', 'HEAD'], { + cwd: directory, + windowsHide: true, + timeout: 10_000, + maxBuffer: 4096, + }, (error, stdout) => error ? reject(error) : resolve(stdout.trim())); + }); +} + +export class DevicePackageManager { + private readonly rootDir: string; + private readonly packagesDir: string; + private readonly stagingDir: string; + private readonly trashDir: string; + private readonly indexPath: string; + private readonly now: () => number; + private readonly createId: () => string; + private readonly runInstall: DevicePackageInstallRunner; + private readonly writeIndex: (filePath: string, value: DevicePackageIndexV1) => Promise; + private readonly activePackages = new Map(); + private readonly pendingCleanup = new Set(); + private operation: Promise = Promise.resolve(); + + constructor(private readonly options: DevicePackageManagerOptions) { + this.rootDir = path.resolve(options.rootDir); + this.packagesDir = path.join(this.rootDir, 'packages'); + this.stagingDir = path.join(this.rootDir, 'staging'); + this.trashDir = path.join(this.rootDir, 'trash'); + this.indexPath = path.join(this.rootDir, 'index.json'); + this.now = options.now ?? Date.now; + this.createId = options.createId ?? randomUUID; + this.runInstall = options.runInstall ?? runPiPackageInstall; + this.writeIndex = options.writeIndex ?? atomicWriteJson; + } + + async prepare(sourceValue: string, requestedByTurnId: string): Promise { + if (!requestedByTurnId.trim()) { + throw new DevicePackageError('local_package_confirmation_required', 'Request turn id is required'); + } + let source; + try { + source = parseDevicePackageSource(sourceValue); + } catch (error) { + throw new DevicePackageError('local_package_source_unsupported', (error as Error).message); + } + const planId = this.createId(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) { + throw new DevicePackageError('local_package_install_failed', 'Install plan id is invalid'); + } + const planRoot = path.join(this.stagingDir, planId); + const packageRoot = path.join(planRoot, 'package'); + await mkdir(planRoot, { recursive: true }); + try { + let resolvedSource = sourceValue.trim(); + if (source.kind === 'file') { + const metadata = await pathMetadata(source.absolutePath); + if (!metadata) throw new DevicePackageError('local_package_not_found', 'Local package source does not exist'); + if (metadata === 'directory') await cp(source.absolutePath, packageRoot, { recursive: true, force: false }); + else { + await mkdir(packageRoot, { recursive: true }); + await cp(source.absolutePath, path.join(packageRoot, path.basename(source.absolutePath)), { force: false }); + } + resolvedSource = source.absolutePath; + } else { + const executablePath = this.options.executablePath; + const cliPath = this.options.cliPath; + if (!executablePath || !cliPath) { + throw new DevicePackageError('local_package_dependency_failed', 'Bundled Pi installer is unavailable'); + } + const agentDir = path.join(planRoot, 'pi-agent'); + const cwd = path.join(planRoot, 'project'); + await this.runInstall({ + source: source.spec, + executablePath, + cliPath, + ...(this.options.npmCliPath ? { npmCliPath: this.options.npmCliPath } : {}), + agentDir, + cwd, + env: installEnvironment(agentDir), + }); + const { DefaultPackageManager, SettingsManager } = await import( + '@earendil-works/pi-coding-agent' + ); + const manager = new DefaultPackageManager({ + cwd, + agentDir, + settingsManager: SettingsManager.inMemory({}, { projectTrusted: false }), + }); + const installedPath = manager.getInstalledPath(source.spec, 'user'); + if (!installedPath) throw new DevicePackageError('local_package_dependency_failed', 'Pi did not install the package'); + if (source.kind === 'npm') { + const installRoot = path.join(agentDir, 'npm'); + await cp(installRoot, packageRoot, { recursive: true, force: false }); + const relativePackage = relativeContainedPath(installRoot, installedPath); + const nested = path.join(packageRoot, ...relativePackage.split('/')); + const inspected = await inspectDevicePackage(nested); + const prefix = relativeContainedPath(packageRoot, nested); + const remapped = { + ...inspected, + skillEntries: inspected.skillEntries.map((entry) => ({ + ...entry, entryPath: `${prefix}/${entry.entryPath}`, + })), + extensionEntries: inspected.extensionEntries.map((entry) => `${prefix}/${entry}`), + }; + return await this.finishPrepare({ + planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, + resolvedSource: `npm:${inspected.displayName}@${inspected.resolvedVersion ?? 'unknown'}`, + inspected: remapped, + }); + } + await cp(installedPath, packageRoot, { recursive: true, force: false }); + const commit = await gitHead(installedPath).catch(() => { + throw new DevicePackageError('local_package_dependency_failed', 'Git package commit cannot be resolved'); + }); + resolvedSource = `${source.spec.replace(/@[^/@]+$/u, '')}@${commit}`; + } + return await this.finishPrepare({ + planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, resolvedSource, + inspected: await inspectDevicePackage( + packageRoot, + source.kind === 'file' + ? path.basename(source.absolutePath, path.extname(source.absolutePath)) + : path.basename(packageRoot), + ), + }); + } catch (error) { + await rm(planRoot, { recursive: true, force: true }).catch(() => undefined); + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_manifest_invalid', (error as Error).message); + } + } + + private async finishPrepare(input: { + planId: string; + planRoot: string; + packageRoot: string; + sourceValue: string; + source: ReturnType; + requestedByTurnId: string; + resolvedSource: string; + inspected: Awaited>; + }): Promise { + const includesExecutableCode = input.inspected.extensionEntries.length > 0; + const resolvedVersion = input.inspected.resolvedVersion + ?? `local-${new Date(this.now()).toISOString().replace(/[-:.]/gu, '')}`; + safeStorageSegment(resolvedVersion); + const preview: InstallPreviewV1 = { + schemaVersion: 1, + planId: input.planId, + expiresAt: new Date(this.now() + PLAN_TTL_MS).toISOString(), + requestedSource: input.sourceValue.trim(), + resolvedSource: input.resolvedSource, + packageId: input.inspected.packageId, + displayName: input.inspected.displayName, + resolvedVersion, + kind: devicePackageKind(input.inspected), + skillEntries: input.inspected.skillEntries, + extensionEntries: input.inspected.extensionEntries, + includesExecutableCode, + ignoredLifecycleScripts: input.inspected.ignoredLifecycleScripts, + warnings: includesExecutableCode ? [EXECUTABLE_WARNING] : [], + scope: 'device-parent-workers', + }; + const record: DevicePackageRecordV1 = { + schemaVersion: 1, + packageId: preview.packageId, + displayName: preview.displayName, + resolvedVersion, + source: { + kind: input.source.kind, + requested: preview.requestedSource, + resolved: preview.resolvedSource, + }, + kind: preview.kind, + skillEntries: preview.skillEntries, + extensionEntries: preview.extensionEntries, + enabled: true, + confirmedExecutableCode: includesExecutableCode, + installedAt: new Date(this.now()).toISOString(), + }; + const plan: StagedPlanV1 = { + schemaVersion: 1, + requestedByTurnId: input.requestedByTurnId, + preview, + record, + }; + await atomicWriteJson(path.join(input.planRoot, 'plan.json'), plan); + return structuredClone(preview); + } + + async commit(planId: string, confirmed: true, confirmationTurnId: string): Promise { + if (confirmed !== true || !confirmationTurnId.trim()) { + throw new DevicePackageError('local_package_confirmation_required', 'Literal confirmation is required'); + } + return await this.withOperation(async () => { + const plan = await this.readPlan(planId); + if (plan.requestedByTurnId === confirmationTurnId) { + throw new DevicePackageError('local_package_confirmation_required', 'Confirm installation in a new user turn'); + } + if (Date.parse(plan.preview.expiresAt) <= this.now()) { + throw new DevicePackageError('local_package_plan_expired', 'Install preview has expired'); + } + if (plan.preview.includesExecutableCode && !plan.preview.warnings.includes(EXECUTABLE_WARNING)) { + throw new DevicePackageError('local_package_confirmation_required', 'Executable permission warning is missing'); + } + const stagedPackage = path.join(this.stagingDir, planId, 'package'); + await this.assertRecordEntries(stagedPackage, plan.record); + const index = await this.readIndex(); + const existing = index.packages.find(({ packageId }) => packageId === plan.record.packageId); + if (existing && existing.source.requested !== plan.record.source.requested) { + throw new DevicePackageError('local_package_install_failed', 'Package id belongs to another source'); + } + const destination = this.packageDirectory(plan.record); + const activeKey = this.packageKey(plan.record); + if (await pathMetadata(destination) && (this.activePackages.get(activeKey) ?? 0) > 0) { + throw new DevicePackageError('local_package_in_use', 'This package version is active in a worker'); + } + await mkdir(path.dirname(destination), { recursive: true }); + await mkdir(this.trashDir, { recursive: true }); + const displaced = await pathMetadata(destination) + ? path.join(this.trashDir, `${plan.record.packageId}-${this.createId()}`) + : null; + if (displaced) await rename(destination, displaced); + let moved = false; + try { + await rename(stagedPackage, destination); + moved = true; + const next: DevicePackageIndexV1 = { + schemaVersion: 1, + generation: index.generation + 1, + packages: [...index.packages.filter(({ packageId }) => packageId !== plan.record.packageId), plan.record] + .sort((left, right) => left.packageId.localeCompare(right.packageId)), + }; + await this.writeIndex(this.indexPath, next); + await rm(path.join(this.stagingDir, planId), { recursive: true, force: true }); + if (existing && existing.resolvedVersion !== plan.record.resolvedVersion) { + await this.cleanupPackage(existing); + } + if (displaced) await rm(displaced, { recursive: true, force: true }).catch(() => undefined); + await this.options.onGenerationChanged?.(cloneIndex(next)); + return cloneIndex(next); + } catch (error) { + if (moved) await rm(destination, { recursive: true, force: true }).catch(() => undefined); + if (displaced) await rename(displaced, destination).catch(() => undefined); + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_install_failed', 'Device package commit failed'); + } + }); + } + + async list(): Promise { + return cloneIndex(await this.readIndex()); + } + + async setEnabled(packageId: string, enabled: boolean): Promise { + return await this.withOperation(async () => { + const index = await this.readIndex(); + const existing = index.packages.find((record) => record.packageId === packageId); + if (!existing) throw new DevicePackageError('local_package_not_installed', 'Device package is not installed'); + if (existing.enabled === enabled) return cloneIndex(index); + const next: DevicePackageIndexV1 = { + schemaVersion: 1, + generation: index.generation + 1, + packages: index.packages.map((record) => record.packageId === packageId + ? { ...record, enabled } + : record), + }; + await this.writeIndex(this.indexPath, next); + await this.options.onGenerationChanged?.(cloneIndex(next)); + return cloneIndex(next); + }); + } + + async uninstall(packageId: string): Promise { + return await this.withOperation(async () => { + const index = await this.readIndex(); + const existing = index.packages.find((record) => record.packageId === packageId); + if (!existing) return cloneIndex(index); + const next: DevicePackageIndexV1 = { + schemaVersion: 1, + generation: index.generation + 1, + packages: index.packages.filter((record) => record.packageId !== packageId), + }; + await this.writeIndex(this.indexPath, next); + await this.cleanupPackage(existing); + await this.options.onGenerationChanged?.(cloneIndex(next)); + return cloneIndex(next); + }); + } + + async resolveEnabledResources(): Promise { + const index = await this.readIndex(); + const enabled = index.packages.filter(({ enabled }) => enabled); + const skillEntries: EnabledDevicePackageResources['skillEntries'] = []; + const extensionPaths: string[] = []; + for (const record of enabled) { + const packageRoot = this.packageDirectory(record); + await this.assertRecordEntries(packageRoot, record); + for (const entry of record.skillEntries) skillEntries.push({ ...entry, packageRoot }); + for (const entry of record.extensionEntries) { + extensionPaths.push(path.join(packageRoot, ...entry.split('/'))); + } + } + return { + generation: index.generation, + packageIds: enabled.map(({ packageId }) => packageId), + packageRefs: enabled.map(({ packageId, resolvedVersion }) => ({ packageId, resolvedVersion })), + skillEntries, + extensionPaths, + }; + } + + registerActiveWorker(refs: readonly { packageId: string; resolvedVersion: string }[]): () => Promise { + const keys = [...new Set(refs.map((record) => this.packageKey(record)))]; + for (const key of keys) this.activePackages.set(key, (this.activePackages.get(key) ?? 0) + 1); + let released = false; + return async () => { + if (released) return; + released = true; + for (const key of keys) { + const count = this.activePackages.get(key) ?? 0; + if (count <= 1) this.activePackages.delete(key); + else this.activePackages.set(key, count - 1); + } + for (const target of [...this.pendingCleanup]) { + const [key, packagePath] = target.split('\u0000', 2); + if (!key || !packagePath || (this.activePackages.get(key) ?? 0) > 0) continue; + this.pendingCleanup.delete(target); + await rm(packagePath, { recursive: true, force: true }).catch(() => undefined); + } + }; + } + + private async readPlan(planId: string): Promise { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) { + throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable'); + } + try { + const value = JSON.parse(await readFile(path.join(this.stagingDir, planId, 'plan.json'), 'utf8')) as unknown; + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.preview)) throw new Error(); + const record = parseRecord(value.record); + const preview = value.preview as unknown as InstallPreviewV1; + if (preview.schemaVersion !== 1 || preview.planId !== planId + || !Array.isArray(preview.warnings) || !Array.isArray(preview.skillEntries) + || !Array.isArray(preview.extensionEntries)) throw new Error(); + return { + schemaVersion: 1, + requestedByTurnId: string(value.requestedByTurnId, 'Request turn id', 256), + preview, + record, + }; + } catch (error) { + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable'); + } + } + + private async readIndex(): Promise { + try { + return parseIndex(JSON.parse(await readFile(this.indexPath, 'utf8')) as unknown); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { schemaVersion: 1, generation: 0, packages: [] }; + } + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); + } + } + + private packageDirectory(record: Pick): string { + return path.join(this.packagesDir, record.packageId, safeStorageSegment(record.resolvedVersion)); + } + + private packageKey(record: Pick): string { + return `${record.packageId}@${record.resolvedVersion}`; + } + + private async assertRecordEntries(packageRoot: string, record: DevicePackageRecordV1): Promise { + if (await pathMetadata(packageRoot) !== 'directory') { + throw new DevicePackageError('local_package_install_failed', 'Device package bytes are unavailable'); + } + for (const entry of [...record.skillEntries.map(({ entryPath }) => entryPath), ...record.extensionEntries]) { + const candidate = path.resolve(packageRoot, ...entry.split('/')); + try { + relativeContainedPath(packageRoot, candidate); + } catch { + throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry escapes its root'); + } + if (await pathMetadata(candidate) !== 'file') { + throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry is unavailable'); + } + } + } + + private async cleanupPackage(record: DevicePackageRecordV1): Promise { + const packagePath = this.packageDirectory(record); + const key = this.packageKey(record); + if ((this.activePackages.get(key) ?? 0) > 0) { + this.pendingCleanup.add(`${key}\u0000${packagePath}`); + return; + } + await rm(packagePath, { recursive: true, force: true }).catch(() => undefined); + } + + private withOperation(operation: () => Promise): Promise { + const result = this.operation.then(operation, operation); + this.operation = result.then(() => undefined, () => undefined); + return result; + } +} diff --git a/electron/coding-packages/device-package-tools.ts b/electron/coding-packages/device-package-tools.ts new file mode 100644 index 0000000..2dfac7a --- /dev/null +++ b/electron/coding-packages/device-package-tools.ts @@ -0,0 +1,173 @@ +import type { CodingPluginToolDefinition } from '../../shared/coding-plugins'; +import type { + DevicePackageIndexV1, + DevicePackageToolDetailsV1, + DevicePackageToolOperation, + InstallPreviewV1, +} from '../../shared/device-packages'; +import type { DevicePackageToolName } from '../../shared/device-packages'; +import type { PiProductToolResult } from '../coding-runtime/pi/product-tools'; +import type { DevicePackageManager } from './device-package-manager'; + +export { DEVICE_PACKAGE_TOOL_NAMES } from '../../shared/device-packages'; + +const EMPTY_OBJECT_SCHEMA = Object.freeze({ + type: 'object', additionalProperties: false, properties: {}, +}); + +function tool( + name: DevicePackageToolName, + label: string, + description: string, + operation: DevicePackageToolOperation, + mutation: 'read' | 'write' | 'destructive', + inputSchema: Readonly>, +): CodingPluginToolDefinition { + return Object.freeze({ + name, + label, + description, + capabilityId: 'makelore.device-packages', + operation, + roles: ['parent'], + mutation, + projectWriteLease: false, + permissions: ['device-packages'], + inputSchema, + }); +} + +export const DEVICE_PACKAGE_TOOL_DEFINITIONS = Object.freeze([ + tool( + 'local_package_prepare', + 'Prepare local package', + 'Resolve an npm, Git, or absolute local Pi package/Skill and show an exact installation preview. This does not install it.', + 'prepare', + 'read', + { + type: 'object', additionalProperties: false, required: ['source'], + properties: { source: { type: 'string', minLength: 1, maxLength: 2048 } }, + }, + ), + tool( + 'local_package_commit', + 'Install local package', + 'Install a prepared device package only after the user confirms in a later message.', + 'commit', + 'write', + { + type: 'object', additionalProperties: false, required: ['planId', 'confirmed'], + properties: { + planId: { type: 'string', minLength: 1, maxLength: 128 }, + confirmed: { const: true }, + }, + }, + ), + tool('local_package_list', 'List local packages', 'List packages installed on this device.', 'list', 'read', EMPTY_OBJECT_SCHEMA), + tool( + 'local_package_set_enabled', + 'Enable or disable local package', + 'Enable or disable an installed device package for future parent workers.', + 'set_enabled', + 'write', + { + type: 'object', additionalProperties: false, required: ['packageId', 'enabled'], + properties: { + packageId: { type: 'string', minLength: 1, maxLength: 128 }, + enabled: { type: 'boolean' }, + }, + }, + ), + tool( + 'local_package_uninstall', + 'Remove local package', + 'Remove an installed package from this device after explicit confirmation.', + 'uninstall', + 'destructive', + { + type: 'object', additionalProperties: false, required: ['packageId', 'confirmed'], + properties: { + packageId: { type: 'string', minLength: 1, maxLength: 128 }, + confirmed: { const: true }, + }, + }, + ), +]); + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Local package tool input is invalid'); + return value as Record; +} + +function exact(input: Record, keys: readonly string[]): void { + const actual = Object.keys(input).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error('Local package tool input has unexpected fields'); + } +} + +function boundedString(value: unknown, name: string, maximum: number): string { + if (typeof value !== 'string' || !value.trim() || value.length > maximum) throw new Error(`${name} is invalid`); + return value.trim(); +} + +function result( + operation: DevicePackageToolOperation, + value: { preview?: InstallPreviewV1; index?: DevicePackageIndexV1 }, +): PiProductToolResult { + const details: DevicePackageToolDetailsV1 = { + schema: 'makelore-device-package.v1', operation, success: true, ...value, + }; + return { content: [{ type: 'text', text: JSON.stringify(details) }], details }; +} + +export class DevicePackageTools { + readonly tools = DEVICE_PACKAGE_TOOL_DEFINITIONS; + + constructor(private readonly manager: DevicePackageManager) {} + + async invoke(toolName: string, turnId: string, value: unknown): Promise { + const input = record(value); + switch (toolName) { + case 'local_package_prepare': { + exact(input, ['source']); + return result('prepare', { + preview: await this.manager.prepare(boundedString(input.source, 'source', 2048), turnId), + }); + } + case 'local_package_commit': { + exact(input, ['planId', 'confirmed']); + return result('commit', { + index: await this.manager.commit( + boundedString(input.planId, 'planId', 128), + input.confirmed as true, + turnId, + ), + }); + } + case 'local_package_list': + exact(input, []); + return result('list', { index: await this.manager.list() }); + case 'local_package_set_enabled': { + exact(input, ['packageId', 'enabled']); + if (typeof input.enabled !== 'boolean') throw new Error('enabled is invalid'); + return result('set_enabled', { + index: await this.manager.setEnabled( + boundedString(input.packageId, 'packageId', 128), + input.enabled, + ), + }); + } + case 'local_package_uninstall': { + exact(input, ['packageId', 'confirmed']); + if (input.confirmed !== true) throw new Error('Literal confirmation is required'); + return result('uninstall', { + index: await this.manager.uninstall(boundedString(input.packageId, 'packageId', 128)), + }); + } + default: + throw new Error('Local package tool is unavailable'); + } + } +} diff --git a/electron/coding-plugins/adapters/game-resource.ts b/electron/coding-plugins/adapters/game-resource.ts index 983cabf..4cbfb37 100644 --- a/electron/coding-plugins/adapters/game-resource.ts +++ b/electron/coding-plugins/adapters/game-resource.ts @@ -4,6 +4,11 @@ import path from 'node:path'; import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import { PiGameAssetTools } from '../../coding-runtime/pi/extensions/game-assets'; import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store'; +import { + type BundledHostedRelease, + MarketplaceHostedAdmissionError, + MarketplaceHostedAdmissionResolver, +} from '../hosted-admission'; import { GameResourceClient, GameResourceClientError, @@ -28,6 +33,8 @@ export interface GameResourcePluginAdapterOptions { readonly marketplace: MarketplacePackageClientPort; readonly packageStore: Pick; readonly makeloreVersion: string; + readonly bundledReleases?: Readonly>; + readonly admissionResolver?: MarketplaceHostedAdmissionResolver; readonly gameAssets?: PiGameAssetTools; } @@ -86,6 +93,9 @@ function clientFailure(error: unknown): AdapterInvocationResult { if (error instanceof GameResourceClientError) { return failure(error.code, error.message, error.status, error.retryable); } + if (error instanceof MarketplaceHostedAdmissionError) { + return failure(error.code, error.message, error.status, error.retryable); + } return failure( 'plugin_backend_unavailable', 'Hosted game-resource service is temporarily unavailable', @@ -159,12 +169,20 @@ function templateConfig(value: unknown): Readonly> { export class GameResourcePluginAdapter implements CodingPluginAdapter { readonly pluginId = PLUGIN_ID; private readonly gameAssets: PiGameAssetTools; + private readonly admissionResolver: MarketplaceHostedAdmissionResolver; constructor(private readonly options: GameResourcePluginAdapterOptions) { this.gameAssets = options.gameAssets ?? new PiGameAssetTools(); + this.admissionResolver = options.admissionResolver ?? new MarketplaceHostedAdmissionResolver({ + marketplace: options.marketplace, + packageStore: options.packageStore, + makeloreVersion: options.makeloreVersion, + bundledReleases: options.bundledReleases, + }); } async inspect(): Promise { + if (this.options.bundledReleases?.[PLUGIN_ID]) return { status: 'ready' }; const installed = await this.options.packageStore.getInstalled(PLUGIN_ID).catch(() => null); return installed ? { status: 'ready' } : { status: 'unconfigured' }; } @@ -287,28 +305,13 @@ export class GameResourcePluginAdapter implements CodingPluginAdapter { releaseId: string; releaseAdmissionId: string; }> { - const installed = context.pluginReleaseId - ? await this.options.packageStore.getInstalledRelease(PLUGIN_ID, context.pluginReleaseId) - : await this.options.packageStore.getInstalled(PLUGIN_ID); - if (!installed || !installed.channel) { - throw new GameResourceClientError('plugin_release_unavailable', 409, false, 'Installed game-resource Release is unavailable'); - } - const resolved = await this.options.marketplace.resolve({ - resolveRequestId: context.requestId, - makeloreVersion: this.options.makeloreVersion, - channel: installed.channel, - installed: [{ - pluginId: installed.pluginId, - releaseId: installed.releaseId, - sha256: installed.sha256, - }], + return this.admissionResolver.resolve({ + pluginId: PLUGIN_ID, + workerSnapshot: { + requestId: context.requestId, + ...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }), + }, }); - const item = resolved.items.find(({ pluginId }) => pluginId === PLUGIN_ID); - if (!item?.releaseId || !item.releaseAdmissionId || item.releaseId !== installed.releaseId - || (item.action !== 'keep' && item.action !== 'install')) { - throw new GameResourceClientError('plugin_runtime_stale', 409, false, 'Game-resource worker Release is stale'); - } - return { releaseId: item.releaseId, releaseAdmissionId: item.releaseAdmissionId }; } } diff --git a/electron/coding-plugins/effective-resolver.ts b/electron/coding-plugins/effective-resolver.ts index 571a177..24c96ef 100644 --- a/electron/coding-plugins/effective-resolver.ts +++ b/electron/coding-plugins/effective-resolver.ts @@ -251,6 +251,23 @@ export class EffectivePluginResolver { constructor(private readonly options: EffectivePluginResolverOptions) {} async resolve(input: EffectivePluginResolverInput): Promise { + return this.resolveSnapshot(input, false); + } + + /** + * Revalidate an already frozen worker before invoking one of its tools. + * A stale Library snapshot is still the last verified account projection; + * the hosted adapter resolves a fresh server Admission for the exact frozen + * Release before dispatch. New workers continue to require a current Library. + */ + async resolveForInvocation(input: EffectivePluginResolverInput): Promise { + return this.resolveSnapshot(input, true); + } + + private async resolveSnapshot( + input: EffectivePluginResolverInput, + allowStaleHostedLibrary: boolean, + ): Promise { const definitions = await this.definitionRecords(); const blockedMarketplacePlugins = marketplaceSkillConflicts(definitions); const assigned = normalizeIds(input.assignedSkillIds); @@ -342,7 +359,9 @@ export class EffectivePluginResolver { unavailableReasons.push(unavailable(definition.id, 'account_required', 'Marketplace account is required')); continue; } - if (!library || (library.stale && definition.runtimeKind !== 'skill_only')) { + if (!library || (library.stale + && definition.runtimeKind !== 'skill_only' + && !allowStaleHostedLibrary)) { unavailableReasons.push(unavailable(definition.id, 'library_unavailable', 'Marketplace Library is unavailable')); continue; } @@ -444,7 +463,7 @@ export class EffectivePluginResolver { // this seam is exclusively for immutable user-installed package roots. return Object.freeze(definitions.flatMap(({ definition, installed, unavailableReason }) => ( !installed || unavailableReason || blockedMarketplacePlugins.has(definition.id) - || definition.acquisitionMode !== 'user_acquired' + || definition.provenance.source !== 'marketplace' ? [] : definition.skills.map((skill) => ({ id: skill.id, @@ -464,11 +483,20 @@ export class EffectivePluginResolver { pluginId: string, releaseId?: string | null, ): Promise { + const records = await this.definitionRecords(); + const bundled = records.find(({ definition }) => ( + definition.id === pluginId + && definition.provenance.source === 'bundled' + && definition.releaseId === releaseId + )); + if (bundled) { + return bundled.installed && !bundled.unavailableReason ? bundled.definition : null; + } if (releaseId && this.options.packageStore) { const installed = await this.options.packageStore.getInstalledRelease(pluginId, releaseId); return installed && !installed.unavailableReason ? installed.definition : null; } - const record = (await this.definitionRecords()).find(({ definition }) => definition.id === pluginId); + const record = records.find(({ definition }) => definition.id === pluginId); return record?.installed && !record.unavailableReason ? record.definition : null; } @@ -491,6 +519,7 @@ export class EffectivePluginResolver { } for (const definition of [...records.values()].map(({ definition }) => definition)) { if (definition.acquisitionMode !== 'user_acquired') continue; + if (definition.provenance.source === 'bundled') continue; let installed = (this.options.installedDefinitions ?? []).some(({ id }) => id === definition.id); let unavailableReason: PluginUnavailableReasonCode | undefined; if (installedIds.has(definition.id)) { diff --git a/electron/coding-plugins/hosted-admission.ts b/electron/coding-plugins/hosted-admission.ts new file mode 100644 index 0000000..e8f8e14 --- /dev/null +++ b/electron/coding-plugins/hosted-admission.ts @@ -0,0 +1,222 @@ +import type { MarketplaceResolveItem } from './account-plugin-cache'; +import type { MarketplacePackageClientPort, PluginPackageStore } from './package-store'; + +const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; +const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const REQUEST_ID_PATTERN = /^[\x21-\x7e]{1,128}$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; + +export interface HostedAdmissionWorkerSnapshot { + /** Main-issued identity for the logical worker operation. */ + readonly requestId: string; + /** Release frozen into this parent worker's effective snapshot. */ + readonly pluginReleaseId?: string; +} + +export interface MarketplaceHostedAdmissionInput { + readonly pluginId: string; + readonly workerSnapshot: HostedAdmissionWorkerSnapshot; +} + +export interface MarketplaceHostedAdmission { + readonly releaseId: string; + readonly releaseAdmissionId: string; +} + +export type MarketplaceHostedAdmissionErrorCode = + | 'plugin_release_unavailable' + | 'plugin_runtime_stale' + | 'plugin_account_changed' + | 'plugin_backend_unavailable'; + +export class MarketplaceHostedAdmissionError extends Error { + constructor( + readonly code: MarketplaceHostedAdmissionErrorCode, + readonly status: number, + readonly retryable: boolean, + message: string, + ) { + super(message); + this.name = 'MarketplaceHostedAdmissionError'; + } +} + +export interface MarketplaceHostedAdmissionResolverOptions { + readonly packageStore: Pick; + readonly marketplace: Pick; + readonly makeloreVersion: string; + readonly bundledReleases?: Readonly>; +} + +export interface BundledHostedRelease { + readonly releaseId: string; + readonly version: string; + readonly channel: 'stable'; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function errorCode(value: unknown): unknown { + return isRecord(value) ? value.code : undefined; +} + +function releaseId(value: unknown): string | null { + return typeof value === 'string' && RELEASE_ID_PATTERN.test(value) ? value : null; +} + +function pluginId(value: unknown): string | null { + return typeof value === 'string' && PLUGIN_ID_PATTERN.test(value) ? value : null; +} + +function requestId(value: unknown): string | null { + return typeof value === 'string' && REQUEST_ID_PATTERN.test(value) ? value : null; +} + +function stale(message: string): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, message); +} + +function unavailable(message: string): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError('plugin_release_unavailable', 409, false, message); +} + +function backendUnavailable(): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError( + 'plugin_backend_unavailable', + 503, + true, + 'Hosted Plugin admission service is temporarily unavailable', + ); +} + +function accountChanged(): MarketplaceHostedAdmissionError { + return new MarketplaceHostedAdmissionError( + 'plugin_account_changed', + 409, + false, + 'Marketplace account changed while resolving hosted Plugin admission', + ); +} + +function matchesFrozenRelease( + item: MarketplaceResolveItem, + installed: Awaited>, + plugin: string, +): item is MarketplaceResolveItem & { + readonly releaseId: string; + readonly releaseAdmissionId: string; +} { + if (!installed) return false; + if (item.pluginId !== plugin || (item.action !== 'keep' && item.action !== 'install')) return false; + if (releaseId(item.releaseId) !== installed.releaseId + || releaseId(item.releaseAdmissionId) === null + || item.releaseAdmissionId === undefined + || item.releaseAdmissionId === null) return false; + if (item.version !== installed.version || item.sha256 !== installed.sha256) return false; + if (item.sizeBytes !== installed.sizeBytes || item.channel !== installed.channel) return false; + return item.sha256 !== null && item.sha256 !== undefined && SHA256_PATTERN.test(item.sha256); +} + +function matchesBundledFrozenRelease( + item: MarketplaceResolveItem, + bundled: BundledHostedRelease, + plugin: string, +): item is MarketplaceResolveItem & { + readonly releaseId: string; + readonly releaseAdmissionId: string; +} { + return item.pluginId === plugin + && item.action === 'keep' + && releaseId(item.releaseId) === bundled.releaseId + && item.version === bundled.version + && item.channel === bundled.channel + && releaseId(item.releaseAdmissionId) !== null + && item.sha256 == null + && item.sizeBytes == null; +} + +/** + * Resolves the current account admission for the exact Release frozen into a + * parent worker. Marketplace identity and Release freshness are the only + * concerns here; provider, payload, billing, and transport routes remain + * outside this boundary. + */ +export class MarketplaceHostedAdmissionResolver { + constructor(private readonly options: MarketplaceHostedAdmissionResolverOptions) {} + + async resolve(input: MarketplaceHostedAdmissionInput): Promise { + const plugin = pluginId(input?.pluginId); + const worker = input?.workerSnapshot; + const request = requestId(worker?.requestId); + const frozenRelease = releaseId(worker?.pluginReleaseId); + if (!plugin || !request) throw unavailable('Hosted Plugin worker identity is unavailable'); + if (!frozenRelease) throw unavailable('Hosted Plugin worker Release is unavailable'); + + const bundled = this.options.bundledReleases?.[plugin]; + let installed: Awaited> = null; + if (bundled) { + if (releaseId(bundled.releaseId) !== frozenRelease || bundled.channel !== 'stable') { + throw unavailable('Bundled hosted Plugin Release is unavailable'); + } + } else { + try { + installed = await this.options.packageStore.getInstalledRelease(plugin, frozenRelease); + } catch (error) { + throw this.mapError(error); + } + if (!installed || installed.pluginId !== plugin || installed.releaseId !== frozenRelease + || installed.unavailableReason || !installed.channel) { + throw unavailable('Installed hosted Plugin Release is unavailable'); + } + } + + let resolved; + try { + resolved = await this.options.marketplace.resolve({ + resolveRequestId: request, + makeloreVersion: this.options.makeloreVersion, + channel: bundled?.channel ?? installed?.channel ?? 'stable', + installed: bundled ? [] : [{ + pluginId: installed!.pluginId, + releaseId: installed!.releaseId, + sha256: installed!.sha256, + }], + }); + } catch (error) { + throw this.mapError(error); + } + + if (resolved.stale || resolved.resolveRequestId !== request) { + throw stale('Hosted Plugin worker admission is stale'); + } + const item = resolved.items.find(({ pluginId: candidate }) => candidate === plugin); + if (item?.action === 'unavailable') { + throw unavailable('Hosted Plugin Release is unavailable'); + } + const matches = item && (bundled + ? matchesBundledFrozenRelease(item, bundled, plugin) + : matchesFrozenRelease(item, installed, plugin)); + if (!matches) { + throw stale('Hosted Plugin worker Release admission is stale'); + } + return Object.freeze({ + releaseId: item.releaseId, + releaseAdmissionId: item.releaseAdmissionId, + }); + } + + private mapError(error: unknown): MarketplaceHostedAdmissionError { + if (error instanceof MarketplaceHostedAdmissionError) return error; + const code = errorCode(error); + if (code === 'marketplace_account_changed' || code === 'plugin_account_changed') return accountChanged(); + return backendUnavailable(); + } +} + +export function createMarketplaceHostedAdmissionResolver( + options: MarketplaceHostedAdmissionResolverOptions, +): MarketplaceHostedAdmissionResolver { + return new MarketplaceHostedAdmissionResolver(options); +} diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index 34faef1..62c6d09 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -10,6 +10,8 @@ import { BUNDLED_CODING_PLUGIN_PREVIEW_SURFACES, BUNDLED_CODING_PLUGIN_SETTINGS_SURFACES, CODE_OWNED_PLUGIN_PERMISSION_IDS, + GAME_RESOURCE_BUNDLED_RELEASE_ID, + GAME_RESOURCE_PLUGIN_ID, DATA_SERVICE_CAPABILITY_IDS, DATA_SERVICE_OPERATION_DEFINITIONS, DATA_SERVICE_PLUGIN_ID, @@ -26,14 +28,32 @@ import { } from '../../shared/coding-plugins'; /** - * P0 deliberately has one statically enumerated package root. Keeping this - * list relative makes it impossible for an environment variable or a project + * The trusted code-owned catalog is statically enumerated. Keeping these + * roots relative makes it impossible for an environment variable or project * file to add a package to the trusted catalog. */ export const BUNDLED_CODING_PLUGIN_ROOTS = Object.freeze([ 'data-service', + 'game-resource', ] as const); +const BUNDLED_CODING_PLUGIN_METADATA = Object.freeze({ + 'data-service': Object.freeze({ + pluginId: DATA_SERVICE_PLUGIN_ID, + runtimeKind: 'bundled_typed' as const, + acquisitionMode: 'system_included' as const, + releaseId: null, + bundledV2: false, + }), + 'game-resource': Object.freeze({ + pluginId: GAME_RESOURCE_PLUGIN_ID, + runtimeKind: 'platform_hosted' as const, + acquisitionMode: 'user_acquired' as const, + releaseId: GAME_RESOURCE_BUNDLED_RELEASE_ID, + bundledV2: true, + }), +}); + const CAPABILITY_MANIFEST_RELATIVE_PATH = 'com.makelore/capability.json'; const PACKAGE_MANIFEST_FILE = 'plugin.json'; const SKILL_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/u; @@ -160,11 +180,13 @@ export interface CodingPluginManifestParseOptions { acquisitionMode?: CodingPluginAcquisitionMode; releaseId?: string | null; provenance?: CodingPluginPackageProvenance; + /** Only the fixed code-owned package catalog may opt schema 2 into bundled delivery. */ + bundledV2?: boolean; } export type CodingPluginLoadOptions = Pick< CodingPluginManifestParseOptions, - 'runtimeKind' | 'acquisitionMode' | 'releaseId' | 'provenance' + 'runtimeKind' | 'acquisitionMode' | 'releaseId' | 'provenance' | 'bundledV2' >; type UnknownRecord = Record; @@ -696,9 +718,16 @@ function trustedMetadata( if (schemaVersion === 1 && releaseId !== null) { fail(filePath, 'trusted metadata.releaseId', 'bundled schema-1 definitions cannot carry a Release ID'); } + const bundledV2 = schemaVersion === 2 && options.bundledV2 === true; + if (options.bundledV2 && !bundledV2) { + fail(filePath, 'trusted metadata.bundledV2', 'is valid only for schema 2 packages'); + } + if (bundledV2 && (runtimeKind !== 'platform_hosted' || releaseId === null)) { + fail(filePath, 'trusted metadata.bundledV2', 'requires a hosted runtime and fixed Release ID'); + } const defaultProvenance: CodingPluginPackageProvenance = { - source: schemaVersion === 1 ? 'bundled' : 'marketplace', - packageRoot: schemaVersion === 1 ? path.basename(packageRoot) : path.resolve(packageRoot), + source: schemaVersion === 1 || bundledV2 ? 'bundled' : 'marketplace', + packageRoot: schemaVersion === 1 || bundledV2 ? path.basename(packageRoot) : path.resolve(packageRoot), }; const provenance = options.provenance ?? defaultProvenance; if (provenance.source !== defaultProvenance.source @@ -1154,13 +1183,15 @@ export async function loadBundledCodingPluginDefinitions( const roots = resolveBundledCodingPluginRootPaths(resourcesRoot); const definitions = await Promise.all(roots.map(async (root, index) => { const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index]; + const metadata = BUNDLED_CODING_PLUGIN_METADATA[expectedRoot]; const definition = await loadCodingPluginDefinition(root, { - runtimeKind: 'bundled_typed', - acquisitionMode: 'system_included', - releaseId: null, + runtimeKind: metadata.runtimeKind, + acquisitionMode: metadata.acquisitionMode, + releaseId: metadata.releaseId, provenance: { source: 'bundled', packageRoot: expectedRoot }, + bundledV2: metadata.bundledV2, }); - if (definition.id !== `makelore.${expectedRoot}`) { + if (definition.id !== metadata.pluginId) { throw new CodingPluginManifestError( path.join(root, PACKAGE_MANIFEST_FILE), 'name', @@ -1179,13 +1210,15 @@ export function loadBundledCodingPluginDefinitionsSync( const roots = resolveBundledCodingPluginRootPaths(resourcesRoot); const definitions = roots.map((root, index) => { const expectedRoot = BUNDLED_CODING_PLUGIN_ROOTS[index]; + const metadata = BUNDLED_CODING_PLUGIN_METADATA[expectedRoot]; const definition = loadCodingPluginDefinitionSync(root, { - runtimeKind: 'bundled_typed', - acquisitionMode: 'system_included', - releaseId: null, + runtimeKind: metadata.runtimeKind, + acquisitionMode: metadata.acquisitionMode, + releaseId: metadata.releaseId, provenance: { source: 'bundled', packageRoot: expectedRoot }, + bundledV2: metadata.bundledV2, }); - if (definition.id !== `makelore.${expectedRoot}`) { + if (definition.id !== metadata.pluginId) { throw new CodingPluginManifestError( path.join(root, PACKAGE_MANIFEST_FILE), 'name', diff --git a/electron/coding-plugins/marketplace-client.ts b/electron/coding-plugins/marketplace-client.ts index d9bf018..547f8fc 100644 --- a/electron/coding-plugins/marketplace-client.ts +++ b/electron/coding-plugins/marketplace-client.ts @@ -44,7 +44,7 @@ const PLUGIN_ID_PATTERN = /^[a-z][a-z0-9.-]{0,127}$/u; const SHA256_PATTERN = /^[a-f0-9]{64}$/u; const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; const REQUEST_ID_PATTERN = /^[\x21-\x7e]{1,128}$/u; -const ETAG_PATTERN = /^"plugins-(\d+)-tp-([A-Za-z0-9._-]{1,128})"$/u; +const ETAG_PATTERN = /^(?:W\/)?"plugins-(\d+)-tp-([A-Za-z0-9._-]{1,128})"$/u; type UnknownRecord = Record; type FetchImplementation = (input: string | URL, init?: RequestInit) => Promise; diff --git a/electron/coding-plugins/registry.ts b/electron/coding-plugins/registry.ts index ab28b47..34321ab 100644 --- a/electron/coding-plugins/registry.ts +++ b/electron/coding-plugins/registry.ts @@ -208,6 +208,9 @@ function validBillingReceipt(value: unknown): value is CapabilityBillingReceiptV } if (value.mode === 'included' && value.status === 'included') return keys.size === 2; if (value.mode === 'external_account' && value.status === 'external') return keys.size === 2; + if (value.mode === 'platform_metered' && value.status === 'receipt_unavailable') { + return keys.size === 2; + } if (value.mode !== 'platform_metered' || !validDecimal(value.reserved_points)) return false; const common = new Set(['mode', 'status', 'reserved_points', 'actual_points', 'usage_amount', 'unit']); if (keys.size !== [...keys].filter((key) => common.has(key)).length) return false; @@ -557,7 +560,7 @@ export class CodingCapabilityRegistryImpl implements CodingCapabilityRegistryPor && candidate.capabilityId === tool.capabilityId && candidate.operation === tool.operation )); - const currentSnapshot = await this.options.effectiveResolver.resolve({ + const currentSnapshot = await this.options.effectiveResolver.resolveForInvocation({ projectId: input.context.projectId, projectPath: input.context.projectPath, assignedSkillIds: input.context.effectiveSnapshot.effectiveSkillIds, diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts index 0172f94..c53de72 100644 --- a/electron/coding-projects/project-config.ts +++ b/electron/coding-projects/project-config.ts @@ -72,6 +72,7 @@ const THINKING_LEVELS = new Set([ 'low', 'medium', 'high', + 'max', ]); function projectConfigPath(projectPath: string): string { diff --git a/electron/coding-runtime/pi/agent-server-process.ts b/electron/coding-runtime/pi/agent-server-process.ts index 2304a3d..41ed00b 100644 --- a/electron/coding-runtime/pi/agent-server-process.ts +++ b/electron/coding-runtime/pi/agent-server-process.ts @@ -490,6 +490,7 @@ export class PiAgentServerProcess { } start(): Promise { + if (this.stopFlight) return this.stopFlight.then(() => this.start()); if (this.startFlight) return this.startFlight; if (this.child && !this.failure) return Promise.resolve(); this.startFlight = this.startServer().finally(() => { @@ -514,7 +515,13 @@ export class PiAgentServerProcess { const generation = ++this.generation; const child = spawn( this.options.executablePath, - [this.options.serverPath, '--runtime-root', this.options.runtimeRoot], + [ + // Enables the parent URL used to resolve dependencies from the packaged Pi runtime root. + '--experimental-import-meta-resolve', + this.options.serverPath, + '--runtime-root', + this.options.runtimeRoot, + ], { cwd: this.options.runtimeRoot, env: buildPiWorkerEnvironment(this.options.configDir), diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index ee480b0..2a9550f 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -40,6 +40,8 @@ interface WorkerRegistrationRecord { projectWriteLeaseToolNames: string[]; role: 'parent' | 'child'; effectiveSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; contextFile: string; runId: string | null; leases: Map; @@ -64,6 +66,8 @@ export interface RegisterPiExtensionWorkerInput { tools?: readonly CodingPluginToolDefinition[]; /** Exact Main-owned resolver output used for this worker generation. */ effectiveSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: readonly string[]; extensionsDir: string; role?: 'parent' | 'child'; runId?: string; @@ -252,6 +256,10 @@ export class PiManagedExtensionHost { projectWriteLeaseToolNames, role, ...(input.effectiveSnapshot ? { effectiveSnapshot: input.effectiveSnapshot } : {}), + ...(input.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: input.devicePackageGeneration }), + ...(input.devicePackageIds ? { devicePackageIds: [...input.devicePackageIds] } : {}), contextFile, runId: role === 'child' ? input.runId as string @@ -424,6 +432,7 @@ export class PiManagedExtensionHost { } const productResult = await this.productTools.execute(value.toolName, { conversationId: record.conversationId, + workerGeneration: record.generation, runId: value.runId, resourceId: value.resourceId, projectId: record.projectId, @@ -596,6 +605,10 @@ export class PiManagedExtensionHost { skillIds: record.skillIds, ...(record.catalogRevision === undefined ? {} : { catalogRevision: record.catalogRevision }), ...(record.effectiveSnapshot ? { effectivePluginSnapshot: record.effectiveSnapshot } : {}), + ...(record.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: record.devicePackageGeneration }), + ...(record.devicePackageIds ? { devicePackageIds: record.devicePackageIds } : {}), allowedToolNames: record.allowedToolNames, tools: record.tools, projectWriteLeaseToolNames: record.projectWriteLeaseToolNames, diff --git a/electron/coding-runtime/pi/model-tools/model-tool-registry.ts b/electron/coding-runtime/pi/model-tools/model-tool-registry.ts new file mode 100644 index 0000000..e8cb10b --- /dev/null +++ b/electron/coding-runtime/pi/model-tools/model-tool-registry.ts @@ -0,0 +1,255 @@ +import type { CodingPluginToolDefinition } from '../../../../shared/coding-plugins'; +import type { ModelToolDetailsV1, ModelWebSearchFailureV1 } from '../../../../shared/model-tools'; +import type { ProviderAccount } from '../../../shared/providers/types'; +import type { + PiProviderDescriptor, + PiProviderSelection, + PiWorkerCredentialProjection, +} from '../provider-config'; +import { + ModelWebSearchError, + createModelWebSearchAdapter, + type FrozenSelectedModel, + type ModelWebSearchAdapter, +} from './web-search'; + +const WEB_SEARCH_TOOL: CodingPluginToolDefinition = Object.freeze({ + name: 'web_search', + label: 'Web search', + description: [ + 'Search the public internet with the currently selected model for current information, facts, and sources.', + 'Use agent_browser only for interactive browser debugging, authenticated pages, and UI actions.', + 'When this tool fails, report the failure. Do not use agent_browser as a fallback.', + ].join(' '), + capabilityId: 'model.web-search', + operation: 'search', + roles: ['parent'], + mutation: 'read', + projectWriteLease: false, + permissions: [], + inputSchema: Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { type: 'string', minLength: 1, maxLength: 2_000 }, + }, + }), +}); + +export interface ModelToolInvocationContext { + conversationId: string; + workerGeneration: number; + runId: string; + resourceId: string; +} + +export interface ModelToolInvocationResult { + content: Array<{ type: 'text'; text: string }>; + details: ModelToolDetailsV1; +} + +export interface RegisterModelToolWorkerInput { + conversationId: string; + generation: number; + account: ProviderAccount; + descriptor: PiProviderDescriptor; + selection: PiProviderSelection; + credential: PiWorkerCredentialProjection; +} + +export interface ModelToolWorkerRegistration { + tools: readonly CodingPluginToolDefinition[]; + dispose(): void; +} + +export interface ModelToolRegistryOptions { + adapter?: ModelWebSearchAdapter; +} + +export interface ModelToolRegistryPort { + registerWorker(input: RegisterModelToolWorkerInput): ModelToolWorkerRegistration; + invoke( + toolName: string, + context: ModelToolInvocationContext, + input: unknown, + signal?: AbortSignal, + ): Promise; +} + +interface WorkerBinding { + selectedModel: FrozenSelectedModel; +} + +function resolvedHeaderValue( + value: string, + credential: PiWorkerCredentialProjection, +): string | null { + if (!value.startsWith('$')) return value; + return credential.env[value.slice(1)]?.trim() || null; +} + +function freezeSelectedModel(input: RegisterModelToolWorkerInput): FrozenSelectedModel | null { + if ( + input.account.id !== input.selection.accountId + || input.descriptor.accountId !== input.selection.accountId + || input.descriptor.runtimeProviderId !== input.selection.runtimeProviderId + ) { + return null; + } + const capability = input.account.metadata + ?.worksSquareModelCapabilities + ?.[input.selection.modelId] + ?.webSearch; + if (!capability || !input.descriptor.baseUrl) return null; + const expectedApi = capability.adapter === 'bailian-chat-completions' + ? 'openai-completions' + : 'openai-responses'; + if (input.descriptor.api !== expectedApi) return null; + + const headers: Record = {}; + for (const [name, rawValue] of Object.entries(input.descriptor.headers)) { + const value = resolvedHeaderValue(rawValue, input.credential); + if (!value) return null; + headers[name] = value; + } + const hasAuthorization = Object.keys(headers).some((name) => name.toLowerCase() === 'authorization'); + const apiKey = input.descriptor.apiKeyEnv + ? input.credential.env[input.descriptor.apiKeyEnv]?.trim() + : undefined; + if (!hasAuthorization && apiKey) headers.Authorization = `Bearer ${apiKey}`; + + return Object.freeze({ + accountId: input.account.id, + runtimeProviderId: input.selection.runtimeProviderId, + modelId: input.selection.modelId, + generation: input.generation, + baseUrl: input.descriptor.baseUrl, + headers: Object.freeze(headers), + capability: Object.freeze({ ...capability }), + }); +} + +function failure( + modelId: string, + error: ModelWebSearchError, +): ModelToolInvocationResult { + const details: ModelWebSearchFailureV1 = { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'failed', + modelId, + error: { + code: error.code, + message: error.message, + httpStatus: error.status, + retryable: error.retryable, + }, + }; + return { + content: [{ type: 'text', text: `${error.code}: ${error.message}` }], + details, + }; +} + +function queryFromInput(value: unknown): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (Object.keys(record).some((key) => key !== 'query')) return null; + const query = typeof record.query === 'string' ? record.query.trim() : ''; + return query && query.length <= 2_000 ? query : null; +} + +function successText(answer: string, sources: readonly { title: string; url: string }[]): string { + if (sources.length === 0) return answer; + return `${answer}\n\nSources:\n${sources.map(({ title, url }) => `- ${title}: ${url}`).join('\n')}`; +} + +export class ModelToolRegistry implements ModelToolRegistryPort { + private readonly adapter: ModelWebSearchAdapter; + private readonly bindings = new Map(); + + constructor(options: ModelToolRegistryOptions = {}) { + this.adapter = options.adapter ?? createModelWebSearchAdapter(); + } + + registerWorker(input: RegisterModelToolWorkerInput): ModelToolWorkerRegistration { + const selectedModel = freezeSelectedModel(input); + if (!selectedModel) return { tools: [], dispose: () => undefined }; + const binding = { selectedModel }; + this.bindings.set(input.conversationId, binding); + return { + tools: [WEB_SEARCH_TOOL], + dispose: () => { + if (this.bindings.get(input.conversationId) === binding) { + this.bindings.delete(input.conversationId); + } + }, + }; + } + + async invoke( + toolName: string, + context: ModelToolInvocationContext, + input: unknown, + signal = new AbortController().signal, + ): Promise { + const binding = this.bindings.get(context.conversationId); + const modelId = binding?.selectedModel.modelId ?? 'unknown'; + if (toolName !== 'web_search') { + return failure(modelId, new ModelWebSearchError( + 'model_web_search_unsupported', + 400, + false, + 'The requested model tool is unavailable', + )); + } + if (!binding || binding.selectedModel.generation !== context.workerGeneration) { + return failure(modelId, new ModelWebSearchError( + 'model_context_changed', + 409, + false, + 'The selected model context changed before Web Search completed', + )); + } + const query = queryFromInput(input); + if (!query) { + return failure(modelId, new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'Web Search query must contain between 1 and 2000 characters', + )); + } + try { + const details = await this.adapter.search({ + query, + selectedModel: binding.selectedModel, + parentTurnId: context.runId, + toolCallId: context.resourceId, + }, signal); + if (this.bindings.get(context.conversationId) !== binding) { + return failure(modelId, new ModelWebSearchError( + 'model_context_changed', + 409, + false, + 'The selected model context changed while Web Search was running', + )); + } + return { + content: [{ type: 'text', text: successText(details.answer, details.sources) }], + details, + }; + } catch (error) { + const normalized = error instanceof ModelWebSearchError + ? error + : new ModelWebSearchError( + 'model_web_search_unavailable', + 503, + true, + 'The selected model Web Search transport is unavailable', + ); + return failure(modelId, normalized); + } + } +} diff --git a/electron/coding-runtime/pi/model-tools/web-search.ts b/electron/coding-runtime/pi/model-tools/web-search.ts new file mode 100644 index 0000000..35ff87a --- /dev/null +++ b/electron/coding-runtime/pi/model-tools/web-search.ts @@ -0,0 +1,326 @@ +import type { ImportedModelWebSearchCapability } from '../../../../shared/imported-model-profile'; +import type { + ModelWebSearchErrorCode, + ModelWebSearchSuccessV1, +} from '../../../../shared/model-tools'; +import { proxyAwareFetch, runWithDeadline } from '../../../utils/proxy-fetch'; + +const MAX_QUERY_CHARS = 2_000; +const MAX_ANSWER_CHARS = 20_000; +const MAX_SOURCE_COUNT = 20; +const MAX_SOURCE_TITLE_CHARS = 240; +const MAX_SOURCE_URL_CHARS = 2_048; +const MAX_RESPONSE_BYTES = 1_048_576; +const DEFAULT_TIMEOUT_MS = 120_000; + +type FetchImplementation = ( + input: string | URL, + init?: RequestInit, +) => Promise; + +export type FrozenSelectedModel = Readonly<{ + accountId: string; + runtimeProviderId: string; + modelId: string; + generation: number; + baseUrl: string; + headers: Readonly>; + capability?: ImportedModelWebSearchCapability; +}>; + +export class ModelWebSearchError extends Error { + constructor( + public readonly code: ModelWebSearchErrorCode, + public readonly status: 400 | 409 | 429 | 502 | 503, + public readonly retryable: boolean, + message: string, + ) { + super(message); + this.name = 'ModelWebSearchError'; + } +} + +export interface ModelWebSearchAdapter { + search(input: Readonly<{ + query: string; + selectedModel: FrozenSelectedModel; + parentTurnId: string; + toolCallId: string; + }>, signal: AbortSignal): Promise; +} + +export interface CreateModelWebSearchAdapterOptions { + fetchImpl?: FetchImplementation; + timeoutMs?: number; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function endpoint(baseUrl: string, pathname: 'responses' | 'chat/completions'): string { + return `${baseUrl.trim().replace(/\/+$/, '')}/${pathname}`; +} + +function requestHeaders(headers: Readonly>): Record { + const result = { ...headers }; + const contentType = Object.keys(result).find((name) => name.toLowerCase() === 'content-type'); + if (contentType) delete result[contentType]; + result['Content-Type'] = 'application/json'; + return result; +} + +async function readBoundedJson(response: Response): Promise { + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an oversized Web Search response', + ); + } + if (!response.body) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an empty Web Search response', + ); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an oversized Web Search response', + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an invalid Web Search response', + ); + } +} + +function normalizedSourceUrl(rawUrl: unknown): { identity: string; url: string } | null { + if (typeof rawUrl !== 'string') return null; + const url = rawUrl.trim(); + if (!url || url.length > MAX_SOURCE_URL_CHARS) return null; + try { + const parsed = new URL(url); + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + || parsed.username || parsed.password) { + return null; + } + const identity = new URL(parsed.toString()); + identity.hash = ''; + return { identity: identity.toString(), url: parsed.toString() }; + } catch { + return null; + } +} + +function sourceCandidate(value: unknown): { title: string; url: string; identity: string } | null { + const record = asRecord(value); + const normalized = normalizedSourceUrl(record?.url); + if (!record || !normalized) return null; + const rawTitle = typeof record.title === 'string' ? record.title.trim() : ''; + let title = rawTitle || new URL(normalized.url).hostname; + if (!title || title.length > MAX_SOURCE_TITLE_CHARS) return null; + title = title.slice(0, MAX_SOURCE_TITLE_CHARS); + return { title, url: normalized.url, identity: normalized.identity }; +} + +function collectSources(payload: Record): Array<{ title: string; url: string }> { + const candidates: unknown[] = []; + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + const message = asRecord(asRecord(choice)?.message); + if (Array.isArray(message?.sources)) candidates.push(...message.sources); + } + const searchInfo = asRecord(payload.search_info ?? payload.searchInfo); + if (Array.isArray(searchInfo?.search_results)) candidates.push(...searchInfo.search_results); + if (Array.isArray(searchInfo?.searchResults)) candidates.push(...searchInfo.searchResults); + + const output = Array.isArray(payload.output) ? payload.output : []; + for (const item of output) { + const outputItem = asRecord(item); + const action = asRecord(outputItem?.action); + if (Array.isArray(action?.sources)) candidates.push(...action.sources); + const content = Array.isArray(outputItem?.content) ? outputItem.content : []; + for (const part of content) { + const annotations = Array.isArray(asRecord(part)?.annotations) + ? asRecord(part)?.annotations as unknown[] + : []; + for (const annotation of annotations) { + const annotationRecord = asRecord(annotation); + if (annotationRecord?.type === 'url_citation') candidates.push(annotationRecord); + } + } + } + + const seen = new Set(); + const sources: Array<{ title: string; url: string }> = []; + for (const candidate of candidates) { + const source = sourceCandidate(candidate); + if (!source || seen.has(source.identity)) continue; + seen.add(source.identity); + sources.push({ title: source.title, url: source.url }); + if (sources.length === MAX_SOURCE_COUNT) break; + } + return sources; +} + +function answerFromPayload(payload: Record): string | null { + if (typeof payload.output_text === 'string' && payload.output_text.trim()) { + return payload.output_text.trim(); + } + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + const content = asRecord(asRecord(choice)?.message)?.content; + if (typeof content === 'string' && content.trim()) return content.trim(); + } + const parts: string[] = []; + const output = Array.isArray(payload.output) ? payload.output : []; + for (const item of output) { + const content = Array.isArray(asRecord(item)?.content) ? asRecord(item)?.content as unknown[] : []; + for (const part of content) { + const text = asRecord(part)?.text; + if (typeof text === 'string' && text.trim()) parts.push(text.trim()); + } + } + return parts.length > 0 ? parts.join('\n') : null; +} + +function mapHttpError(response: Response): ModelWebSearchError { + if (response.status === 429) { + return new ModelWebSearchError( + 'model_web_search_rate_limited', + 429, + false, + 'The selected model rate-limited Web Search', + ); + } + return new ModelWebSearchError( + 'model_web_search_unavailable', + 503, + true, + 'The selected model Web Search transport is unavailable', + ); +} + +export function createModelWebSearchAdapter( + options: CreateModelWebSearchAdapterOptions = {}, +): ModelWebSearchAdapter { + const fetchImpl = options.fetchImpl ?? proxyAwareFetch; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return { + async search(input, signal) { + const query = input.query.trim(); + if (!query || query.length > MAX_QUERY_CHARS) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'Web Search query must contain between 1 and 2000 characters', + ); + } + const capability = input.selectedModel.capability; + if (!capability || capability.supportsForcedSearch !== true) { + throw new ModelWebSearchError( + 'model_web_search_unsupported', + 400, + false, + 'The selected model does not support Web Search', + ); + } + const isResponses = capability.adapter === 'openai-responses' + || capability.adapter === 'bailian-responses'; + const url = endpoint(input.selectedModel.baseUrl, isResponses ? 'responses' : 'chat/completions'); + const body = isResponses + ? { + model: input.selectedModel.modelId, + input: query, + tools: [{ type: 'web_search' }], + tool_choice: 'required', + } + : { + model: input.selectedModel.modelId, + messages: [{ role: 'user', content: query }], + stream: false, + enable_search: true, + search_options: { forced_search: true }, + }; + let response: Response; + try { + response = await runWithDeadline( + async (deadlineSignal) => await fetchImpl(url, { + method: 'POST', + headers: requestHeaders(input.selectedModel.headers), + body: JSON.stringify(body), + signal: deadlineSignal, + }), + timeoutMs, + signal, + ); + } catch (error) { + if (error instanceof ModelWebSearchError) throw error; + throw new ModelWebSearchError( + 'model_web_search_unavailable', + 503, + true, + 'The selected model Web Search transport is unavailable', + ); + } + if (!response.ok) throw mapHttpError(response); + const payload = asRecord(await readBoundedJson(response)); + const answer = payload ? answerFromPayload(payload) : null; + if (!payload || !answer || answer.length > MAX_ANSWER_CHARS) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an invalid Web Search result', + ); + } + return { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId: input.selectedModel.modelId, + answer, + sources: collectSources(payload), + sourceMode: capability.sourceMode, + }; + }, + }; +} diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index 8ae250d..36970ac 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -25,6 +25,9 @@ import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; import { projectTaskState } from './extensions/task-state'; +import type { ModelToolRegistryPort } from './model-tools/model-tool-registry'; +import type { DevicePackageTools } from '../../coding-packages/device-package-tools'; +import { DEVICE_PACKAGE_TOOL_NAMES } from '../../../shared/device-packages'; export type PiProductToolName = | 'agent_browser' @@ -45,6 +48,7 @@ export function isPiProductToolName(value: unknown): value is PiProductToolName export interface PiProductToolContext { conversationId: string; + workerGeneration?: number; runId: string; resourceId: string; projectId: string; @@ -67,6 +71,8 @@ export interface PiProductToolsOptions { getPluginSkillSources?(): readonly ProductCodingPluginSkillSource[] | Promise; capabilityRegistry?: CodingCapabilityRegistry; + modelToolRegistry?: ModelToolRegistryPort; + devicePackageTools?: DevicePackageTools; } export class PiProductTools { @@ -143,6 +149,19 @@ export class PiProductTools { context: PiProductToolContext, input: unknown, ): Promise { + if (DEVICE_PACKAGE_TOOL_NAMES.includes(toolName as typeof DEVICE_PACKAGE_TOOL_NAMES[number])) { + if (!this.options.devicePackageTools) throw new Error('Device package management is unavailable'); + return await this.options.devicePackageTools.invoke(toolName, context.runId, input); + } + if (toolName === 'web_search') { + if (!this.options.modelToolRegistry) throw new Error('Model Web Search is unavailable'); + return await this.options.modelToolRegistry.invoke('web_search', { + conversationId: context.conversationId, + workerGeneration: context.workerGeneration ?? 0, + runId: context.runId, + resourceId: context.resourceId, + }, input); + } if (toolName === 'agent_browser') { return await this.browser.execute(context, input); } diff --git a/electron/coding-runtime/pi/provider-config.ts b/electron/coding-runtime/pi/provider-config.ts index ad6d374..a3ac93f 100644 --- a/electron/coding-runtime/pi/provider-config.ts +++ b/electron/coding-runtime/pi/provider-config.ts @@ -17,7 +17,10 @@ import { normalizeImportedUserModelId, selectUserModelRuntimeAccounts, } from '../../../shared/user-model-config'; -import { getImportedModelProfile } from '../../../shared/imported-model-profile'; +import { + getImportedModelProfile, + thinkingLevelMapForImportedModelCapability, +} from '../../../shared/imported-model-profile'; const PI_ENV_PREFIX = 'MAKELORE_PI'; const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; @@ -302,6 +305,7 @@ function modelDescriptor( )); const backend = backendModels.get(modelId); const profile = getImportedModelProfile(modelId); + const serverCapability = account.metadata?.worksSquareModelCapabilities?.[modelId]; const backendInput = Array.isArray(backend?.input) ? backend.input.filter((input): input is 'text' | 'image' => input === 'text' || input === 'image') : []; @@ -319,17 +323,28 @@ function modelDescriptor( id: modelId, name: summary?.name || (typeof backend?.name === 'string' && backend.name.trim()) || modelId, input: supportsImage ? ['text', 'image'] : ['text'], - reasoning: summary?.supportsReasoning === true - || profile?.pi?.reasoning === true - || backend?.reasoning === true, + reasoning: serverCapability + ? serverCapability.reasoningEfforts.length > 0 + : summary?.supportsReasoning === true + || profile?.pi?.reasoning === true + || backend?.reasoning === true, ...(contextWindow ? { contextWindow } : {}), ...(maxOutputTokens ? { maxOutputTokens } : {}), - ...(compat || profile?.pi?.compat || enforcedCompat - ? { compat: { ...compat, ...profile?.pi?.compat, ...enforcedCompat } } - : {}), - ...(profile?.pi?.thinkingLevelMap - ? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } } + ...(compat || profile?.pi?.compat || enforcedCompat || serverCapability + ? { + compat: { + ...compat, + ...profile?.pi?.compat, + ...enforcedCompat, + ...(serverCapability ? { supportsReasoningEffort: true } : {}), + }, + } : {}), + ...(serverCapability + ? { thinkingLevelMap: thinkingLevelMapForImportedModelCapability(serverCapability) } + : profile?.pi?.thinkingLevelMap + ? { thinkingLevelMap: { ...profile.pi.thinkingLevelMap } } + : {}), }; } diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index 4bb49f9..fc01583 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -51,6 +51,9 @@ export interface MaterializePiAgentResourcesOptions { skillRoots?: readonly string[]; /** The exact resolver output for this worker generation. */ effectiveSnapshot?: EffectivePluginSnapshot; + /** Device-installed packages resolved once for this parent worker generation. */ + devicePackageGeneration?: number; + devicePackageIds?: readonly string[]; revision: PiManagedInputRevision; } @@ -65,6 +68,8 @@ export interface PiAgentResourceManifest { catalogRevision: number; revision: PiManagedInputRevision; effectivePluginSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; } export interface PiAgentResourceSnapshot { @@ -79,6 +84,8 @@ export interface PiAgentResourceSnapshot { catalogRevision: number; revision: PiManagedInputRevision; effectivePluginSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; summary: { projectId: string; agentId: string; @@ -87,6 +94,8 @@ export interface PiAgentResourceSnapshot { catalogRevision: number; revision: PiManagedInputRevision; effectivePluginSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; }; } @@ -285,6 +294,10 @@ export async function materializePiAgentResources( catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), + ...(options.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: catalogRevision(options.devicePackageGeneration) }), + ...(options.devicePackageIds ? { devicePackageIds: [...options.devicePackageIds] } : {}), }; await Promise.all([ atomicWriteText(promptPath, options.prompt), @@ -303,6 +316,10 @@ export async function materializePiAgentResources( catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), + ...(options.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: catalogRevision(options.devicePackageGeneration) }), + ...(options.devicePackageIds ? { devicePackageIds: [...options.devicePackageIds] } : {}), summary: { projectId, agentId, @@ -311,6 +328,10 @@ export async function materializePiAgentResources( catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), + ...(options.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: catalogRevision(options.devicePackageGeneration) }), + ...(options.devicePackageIds ? { devicePackageIds: [...options.devicePackageIds] } : {}), }, }; } diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index 65f81ef..e7fdd41 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -85,6 +85,9 @@ import { } from './session-projector'; import { PiManagedExtensionHost } from './extension-host'; import type { PiSubagentScheduler } from './subagent'; +import type { ModelToolRegistryPort } from './model-tools/model-tool-registry'; +import type { DevicePackageManager } from '../../coding-packages/device-package-manager'; +import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import type { CodingCapabilityRegistry, ResolvedWorkerResources } from '../../coding-plugins/registry'; import { PiInteractionStore, @@ -152,6 +155,9 @@ export interface PiManagedWorkerOpenerOptions { onTelemetry?: (event: PiRuntimeTelemetryEvent) => void; extensionHost: PiManagedExtensionHost; capabilityRegistry?: CodingCapabilityRegistry; + modelToolRegistry?: ModelToolRegistryPort; + devicePackageManager?: Pick; + devicePackageTools?: readonly CodingPluginToolDefinition[]; } interface PiRpcSessionStateProjection { @@ -253,12 +259,19 @@ export function createPiManagedWorkerOpener( role: 'parent', }) : fallbackWorkerResources(registered.agent.skillIds); + const deviceResources = options.devicePackageManager + ? await options.devicePackageManager.resolveEnabledResources() + : undefined; + const combinedSkillEntries = [ + ...workerResources.skillEntries, + ...(deviceResources?.skillEntries ?? []), + ]; const resources = await materializePiAgentResources({ userDataDir: options.userDataDir, projectId: input.conversation.projectId, agentId: registered.agent.id, prompt: registered.agent.prompt, - skillEntries: workerResources.skillEntries, + skillEntries: combinedSkillEntries, catalogRevision: workerResources.catalogRevision, bundledSkillsDir: options.bundledSkillsDir, ...(workerResources.skillRoots @@ -267,6 +280,12 @@ export function createPiManagedWorkerOpener( ...(workerResources.effectiveSnapshot ? { effectiveSnapshot: workerResources.effectiveSnapshot } : {}), + ...(deviceResources + ? { + devicePackageGeneration: deviceResources.generation, + devicePackageIds: deviceResources.packageIds, + } + : {}), revision: input.revision, }); const credential = await buildPiWorkerCredentialProjection({ @@ -277,19 +296,43 @@ export function createPiManagedWorkerOpener( ? { localProxyCredential: await options.getLocalProxyCredential() } : {}), }); - const extension = await options.extensionHost.registerWorker({ + const modelTools = options.modelToolRegistry?.registerWorker({ conversationId: input.conversation.conversationId, generation: input.generation, - projectId: input.conversation.projectId, - projectPath: registered.projectPath, - skillEntries: workerResources.skillEntries, - catalogRevision: workerResources.catalogRevision, - tools: workerResources.tools, - ...(workerResources.effectiveSnapshot - ? { effectiveSnapshot: workerResources.effectiveSnapshot } - : {}), - extensionsDir: managedPaths.extensionsDir, + account, + descriptor, + selection, + credential, }); + let extension; + try { + extension = await options.extensionHost.registerWorker({ + conversationId: input.conversation.conversationId, + generation: input.generation, + projectId: input.conversation.projectId, + projectPath: registered.projectPath, + skillEntries: combinedSkillEntries, + catalogRevision: workerResources.catalogRevision, + tools: [ + ...workerResources.tools, + ...(modelTools?.tools ?? []), + ...(options.devicePackageTools ?? []), + ], + ...(workerResources.effectiveSnapshot + ? { effectiveSnapshot: workerResources.effectiveSnapshot } + : {}), + ...(deviceResources + ? { + devicePackageGeneration: deviceResources.generation, + devicePackageIds: deviceResources.packageIds, + } + : {}), + extensionsDir: managedPaths.extensionsDir, + }); + } catch (error) { + modelTools?.dispose(); + throw error; + } recordManagedMilestone( options.onTelemetry, input, @@ -317,6 +360,9 @@ export function createPiManagedWorkerOpener( additionalArgs: [ ...buildPiManagedInputArgs(selection, resources), '--extension', extension.extensionPath, + ...(deviceResources?.extensionPaths.flatMap((extensionPath) => [ + '--extension', extensionPath, + ]) ?? []), ...(input.fork ? ['--fork', input.fork.sourceSession.piSessionId] : []), '--session-id', sessionKey, ], @@ -328,6 +374,9 @@ export function createPiManagedWorkerOpener( const releaseActivePluginReleases = workerResources.effectiveSnapshot ? options.registerActivePluginReleases?.(workerResources.effectiveSnapshot.pluginReleaseIds) : undefined; + const releaseActiveDevicePackages = deviceResources + ? options.devicePackageManager?.registerActiveWorker(deviceResources.packageRefs) + : undefined; let managedResourcesDisposed = false; const disposeManagedResources = async (): Promise => { if (managedResourcesDisposed) return; @@ -335,7 +384,15 @@ export function createPiManagedWorkerOpener( try { await extension.dispose(); } finally { - await releaseActivePluginReleases?.(); + try { + modelTools?.dispose(); + } finally { + try { + await releaseActiveDevicePackages?.(); + } finally { + await releaseActivePluginReleases?.(); + } + } } }; let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => { @@ -459,6 +516,7 @@ const PRODUCT_THINKING_LEVELS = new Set([ 'low', 'medium', 'high', + 'max', ]); function productThinkingLevel(value: unknown): ProductModelRef['thinkingLevel'] | null { @@ -1213,6 +1271,10 @@ export class PiConversationRuntime implements CodingConversationRuntime { this.pool.markResourcesStale(); } + async refreshResources(): Promise { + await this.pool.refreshResources(); + } + subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index 3ade412..c27d9c1 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -422,6 +422,18 @@ export class PiWorkerPool { return this.revisions.markResourcesStale(); } + async refreshResources(): Promise { + this.revisions.markResourcesStale(); + const idleWorkers = [...this.workers.values()].filter((record) => ( + (record.state === 'ready' || record.state === 'idle') + && !this.activeRuns.has(record.conversation.conversationId) + )); + await Promise.all(idleWorkers.map(async (record) => { + if (this.workers.get(record.conversation.conversationId) !== record) return; + await this.ensureFresh(record); + })); + } + async fork( sourceConversationId: string, conversation: PrepareConversationInput, diff --git a/electron/services/providers/provider-service.ts b/electron/services/providers/provider-service.ts index 1aa53c9..dec19e2 100644 --- a/electron/services/providers/provider-service.ts +++ b/electron/services/providers/provider-service.ts @@ -29,6 +29,7 @@ import { } from '../../utils/secure-storage'; import type { ProviderWithKeyInfo } from '../../shared/providers/types'; import { logger } from '../../utils/logger'; +import { normalizeImportedModelCapabilities } from '../../../shared/user-model-config'; function maskApiKey(apiKey: string | null): string | null { if (!apiKey) return null; @@ -69,6 +70,12 @@ function normalizeSyncedMetadata(metadata: Record): ProviderAcc result.customModels = customModels; } } + const worksSquareModelCapabilities = normalizeImportedModelCapabilities( + metadata.worksSquareModelCapabilities, + ); + if (worksSquareModelCapabilities) { + result.worksSquareModelCapabilities = worksSquareModelCapabilities; + } return result; } diff --git a/electron/shared/providers/types.ts b/electron/shared/providers/types.ts index 6f196e0..6994fbc 100644 --- a/electron/shared/providers/types.ts +++ b/electron/shared/providers/types.ts @@ -1,3 +1,5 @@ +import type { ImportedModelCapabilities } from '../../../shared/imported-model-profile'; + export const PROVIDER_TYPES = [ 'anthropic', 'openai', @@ -133,6 +135,7 @@ export interface ProviderAccount { email?: string; resourceUrl?: string; customModels?: string[]; + worksSquareModelCapabilities?: ImportedModelCapabilities; worksSquareCredentialMode?: string; worksSquareCredentialExpiresAt?: string; worksSquareOneApiBaseUrl?: string; diff --git a/resources/coding-plugins/game-resource/com.makelore/capability.json b/resources/coding-plugins/game-resource/com.makelore/capability.json new file mode 100644 index 0000000..c438a90 --- /dev/null +++ b/resources/coding-plugins/game-resource/com.makelore/capability.json @@ -0,0 +1,288 @@ +{ + "schemaVersion": 2, + "pluginId": "makelore.game-resource", + "contractVersion": 1, + "scope": "project", + "runtime": { + "kind": "platform_hosted", + "protocol": "makelore-hosted.v1" + }, + "skills": [ + { + "id": "game-resource", + "entry": "../skills/game-resource/SKILL.md", + "grants": [ + "game-resource.generate", + "game-resource.library" + ] + } + ], + "tools": [ + { + "name": "game_resource_templates", + "label": "List game resource templates", + "description": "List the currently available hosted templates for pixel-art or HD generation.", + "capabilityId": "game-resource.generate", + "operation": "templates", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["hosted.game-resource.templates"], + "executionMode": "synchronous", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["kind"], + "properties": { + "kind": {"type": "string", "minLength": 2, "maxLength": 5} + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "templates"], + "properties": { + "kind": {"type": "string", "minLength": 2, "maxLength": 5}, + "templates": { + "type": "array", + "maxItems": 100, + "items": {"type": "string", "minLength": 1, "maxLength": 200} + } + } + } + }, + { + "name": "game_resource_generate", + "label": "Generate game resource", + "description": "Submit one hosted pixel-art or HD game-resource generation job.", + "capabilityId": "game-resource.generate", + "operation": "generate", + "roles": ["parent"], + "mutation": "write", + "projectWriteLease": false, + "permissions": ["hosted.game-resource.generate"], + "executionMode": "job", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "templateName", "requirement", "confirmed"], + "properties": { + "kind": {"type": "string", "minLength": 2, "maxLength": 5}, + "templateName": {"type": "string", "minLength": 1, "maxLength": 200}, + "templateConfigJson": {"type": "string", "maxLength": 16384}, + "requirement": {"type": "string", "minLength": 1, "maxLength": 12000}, + "aspectRatio": {"type": "string", "minLength": 1, "maxLength": 40}, + "temperature": {"type": "number", "minimum": 0, "maximum": 2}, + "jobName": {"type": "string", "minLength": 1, "maxLength": 200}, + "modelName": {"type": "string", "minLength": 1, "maxLength": 200}, + "resolution": {"type": "string", "minLength": 1, "maxLength": 40}, + "hdRemoveBgMode": {"type": "string", "minLength": 1, "maxLength": 80}, + "threadId": {"type": "string", "minLength": 1, "maxLength": 200}, + "confirmed": {"type": "boolean"}, + "referencePaths": { + "type": "array", + "maxItems": 8, + "items": {"type": "string", "minLength": 1, "maxLength": 1024} + } + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["executionId", "status", "outputCount"], + "properties": { + "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, + "status": {"type": "string", "maxLength": 32}, + "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, + "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, + "errorCode": {"type": "string", "minLength": 1, "maxLength": 128} + } + } + }, + { + "name": "game_resource_status", + "label": "Game resource status", + "description": "Read and refresh one hosted game-resource generation job.", + "capabilityId": "game-resource.generate", + "operation": "status", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["hosted.game-resource.status"], + "executionMode": "synchronous", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["executionId"], + "properties": { + "executionId": {"type": "string", "minLength": 1, "maxLength": 36} + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["executionId", "status", "outputCount", "generationBilling"], + "properties": { + "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, + "status": {"type": "string", "minLength": 1, "maxLength": 32}, + "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, + "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, + "errorCode": {"type": "string", "minLength": 1, "maxLength": 128}, + "generationBilling": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "status", "reserved_points", "usage_amount", "unit"], + "properties": { + "mode": {"type": "string", "minLength": 1, "maxLength": 32}, + "status": {"type": "string", "maxLength": 32}, + "reserved_points": {"type": "string", "maxLength": 32}, + "actual_points": {"type": "string", "maxLength": 32}, + "usage_amount": {"type": "integer", "minimum": 0, "maximum": 1}, + "unit": {"type": "string", "minLength": 1, "maxLength": 32} + } + } + } + } + }, + { + "name": "game_resource_save_output", + "label": "Save generated game resource", + "description": "Save one completed hosted output into a new file inside the current project.", + "capabilityId": "game-resource.library", + "operation": "save_output", + "roles": ["parent"], + "mutation": "write", + "projectWriteLease": true, + "permissions": ["hosted.game-resource.save-output"], + "executionMode": "synchronous", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["executionId", "relativePath", "confirmed"], + "properties": { + "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, + "outputIndex": {"type": "integer", "minimum": 0, "maximum": 99}, + "relativePath": {"type": "string", "minLength": 1, "maxLength": 1024}, + "confirmed": {"type": "boolean"} + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["savedPath", "bytes"], + "properties": { + "savedPath": {"type": "string", "minLength": 1, "maxLength": 1024}, + "bytes": {"type": "integer", "minimum": 1, "maximum": 33554432} + } + } + }, + { + "name": "game_resource_cancel", + "label": "Cancel game resource", + "description": "Cancel one non-terminal hosted generation job.", + "capabilityId": "game-resource.generate", + "operation": "cancel", + "roles": ["parent"], + "mutation": "write", + "projectWriteLease": false, + "permissions": ["hosted.game-resource.cancel"], + "executionMode": "synchronous", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["executionId"], + "properties": { + "executionId": {"type": "string", "minLength": 1, "maxLength": 36} + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["executionId", "status", "outputCount", "generationBilling"], + "properties": { + "executionId": {"type": "string", "minLength": 1, "maxLength": 36}, + "status": {"type": "string", "minLength": 1, "maxLength": 32}, + "outputCount": {"type": "integer", "minimum": 0, "maximum": 100}, + "pollIntervalSeconds": {"type": "integer", "minimum": 1, "maximum": 300}, + "errorCode": {"type": "string", "minLength": 1, "maxLength": 128}, + "generationBilling": { + "type": "object", + "additionalProperties": false, + "required": ["mode", "status", "reserved_points", "usage_amount", "unit"], + "properties": { + "mode": {"type": "string", "minLength": 1, "maxLength": 32}, + "status": {"type": "string", "maxLength": 32}, + "reserved_points": {"type": "string", "maxLength": 32}, + "actual_points": {"type": "string", "maxLength": 32}, + "usage_amount": {"type": "integer", "minimum": 0, "maximum": 1}, + "unit": {"type": "string", "minLength": 1, "maxLength": 32} + } + } + } + } + }, + { + "name": "game_asset_browser", + "label": "Browse game assets", + "description": "Open the project game-asset browser for user selection.", + "capabilityId": "game-resource.library", + "operation": "browse", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["hosted.game-resource.browse"], + "executionMode": "synchronous", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "invocationId": {"type": "string", "minLength": 1, "maxLength": 200} + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["invocationId", "status"], + "properties": { + "invocationId": {"type": "string", "minLength": 1, "maxLength": 200}, + "status": {"type": "string", "minLength": 1, "maxLength": 32} + } + } + }, + { + "name": "game_asset_review", + "label": "Review game asset", + "description": "Open a project game asset for user review and decision.", + "capabilityId": "game-resource.library", + "operation": "review", + "roles": ["parent"], + "mutation": "read", + "projectWriteLease": false, + "permissions": ["hosted.game-resource.review"], + "executionMode": "synchronous", + "inputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["candidateIds"], + "properties": { + "candidateIds": { + "type": "array", + "maxItems": 200, + "items": {"type": "string", "minLength": 1, "maxLength": 200} + }, + "invocationId": {"type": "string", "minLength": 1, "maxLength": 200} + } + }, + "outputSchema": { + "type": "object", + "additionalProperties": false, + "required": ["invocationId", "status"], + "properties": { + "invocationId": {"type": "string", "minLength": 1, "maxLength": 200}, + "status": {"type": "string", "minLength": 1, "maxLength": 32} + } + } + } + ] +} diff --git a/resources/coding-plugins/game-resource/plugin.json b/resources/coding-plugins/game-resource/plugin.json new file mode 100644 index 0000000..14907cd --- /dev/null +++ b/resources/coding-plugins/game-resource/plugin.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "makelore.game-resource", + "version": "1.0.0", + "description": "Generate and review game resources through MakeLore's hosted provider", + "author": { + "name": "MakeLore" + }, + "extensions": { + "com.makelore": { + "capabilityManifest": "./com.makelore/capability.json" + } + } +} diff --git a/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md b/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md new file mode 100644 index 0000000..767ed63 --- /dev/null +++ b/resources/coding-plugins/game-resource/skills/game-resource/SKILL.md @@ -0,0 +1,35 @@ +--- +name: game-resource +description: 当用户明确要求为当前 MakeLore 项目生成、查找或审查游戏资源时使用;生成会消耗 Token Point,提交前必须说明并取得用户确认。 +--- + +# MakeLore 游戏资源 + +此 Skill 只使用 `makelore.game-resource` 提供的工具。不要请求、保存或展示 +Meowa 凭据、Provider URL、Provider job ID,也不要直接访问第三方接口。 + +## 生成流程 + +1. 先确认资源用途、像素或高清类型、画面要求、比例和实际需要的参考图;用 + `game_resource_templates` 获取当前可用模板,不要猜模板名。 +2. 明确告诉用户生成按 Token Point 用量计费,并等待用户确认本次具体生成请求。 +3. 确认后只调用一次 `game_resource_generate`。结果不确定、超时或 + `pending_review` 时不得换 logical operation 重试;报告状态并使用原 + `executionId` 查询。 +4. 用 `game_resource_status` 查询同一个 execution,直到 succeeded、failed、 + cancelled 或 pending_review。轮询间隔遵守返回的 `pollIntervalSeconds`。 +5. 只有用户明确要求取消时才调用 `game_resource_cancel`。 +6. succeeded 后,只有用户明确给出项目内目标路径并确认保存时,才调用 + `game_resource_save_output`。该工具只创建新文件,不覆盖现有文件。 + +## 浏览与审查 + +`game_asset_browser` 与 `game_asset_review` 只处理当前项目的本地候选资源和用户 +确认,不会生成资源,也不会产生新的 Token Point 交易。不得把本地路径当作 +Provider 地址或通过生成工具上传未获用户同意的文件。 + +## 完成标准 + +报告必须区分提交、Provider 状态和 Token Point receipt。只引用工具实际返回的 +execution、status、outputCount 与 billing;不要猜测余额、价格、Provider 成本或 +输出路径。没有 settled receipt 时不得声称计费已最终完成。 diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index 627494f..70729e3 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -45,6 +45,16 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ 'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations', ]), + modelTools: Object.freeze([ + 'makelore-model-tool.v1', + 'model.web-search', + 'forced_search', + ]), + devicePackages: Object.freeze([ + 'makelore-device-package.v1', + '/api/coding/device-packages', + 'device-parent-workers', + ]), mainRoutes: Object.freeze([ '/api/coding/plugin-marketplace', 'plugin-marketplace\\/install\\/', @@ -61,12 +71,14 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ '我的插件', ]), }); -const FORBIDDEN_MEOWA_ARTIFACT_MARKERS = Object.freeze([ +const FORBIDDEN_PROVIDER_AUTHORITY_MARKERS = Object.freeze([ 'MEOWA_API_KEY', 'MEOWA_API_URL', 'MEOWA_GAME_ASSETS_SHARED_SECRET', '/api/coding/meowa-game-assets', 'https://api.meowa.ai', + 'WEB_SEARCH_OPENAI_API_KEY', + 'https://api.openai.com/v1/responses', ]); const CODE_OWNED_PLUGIN_SIGNING_KEYS_SOURCE_MARKER = 'makelore.plugin-trust.code-owned.v1'; const PI_AI_PROVIDER_PREFIX = 'pi-runtime/node_modules/@earendil-works/pi-ai/dist/providers/'; @@ -309,11 +321,11 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust if (missing.length > 0) { throw new Error(`Packaged app.asar does not contain Marketplace contract markers: ${missing.join(', ')}`); } - const forbidden = FORBIDDEN_MEOWA_ARTIFACT_MARKERS.filter((marker) => ( + const forbidden = FORBIDDEN_PROVIDER_AUTHORITY_MARKERS.filter((marker) => ( appAsarContents.includes(Buffer.from(marker)) )); if (forbidden.length > 0) { - throw new Error(`Packaged app.asar still contains legacy Meowa client authority: ${forbidden.join(', ')}`); + throw new Error(`Packaged app.asar still contains provider authority: ${forbidden.join(', ')}`); } const hasEmptyCodeOwnedTrust = /(?:CODE_OWNED_PLUGIN_SIGNING_KEYS\s*=\s*)?Object\.freeze\(\s*\{\}\s*(?:as\s+[^)]*)?\)/u.test(productionTrustSource); if (!hasEmptyCodeOwnedTrust @@ -327,6 +339,7 @@ export function verifyMarketplaceClientArtifact(appAsarContents, productionTrust schema2SkillOnly: true, schema2PlatformHosted: true, legacyMeowaClientAuthorityAbsent: true, + webSearchProviderAuthorityAbsent: true, productionTrust: 'official-key-absent-fail-closed', libraryInstallAndEffectiveRoutes: true, rendererAssets: true, @@ -612,8 +625,12 @@ export async function verifyBundledCodingPluginResources({ } const sdkAssetPaths = packageFiles.filter((file) => SDK_ASSET_PATH_PATTERN.test(file)); - if (sdkAssetPaths.length === 0) throw new Error(`Packaged coding plugin has no SDK assets: ${packageName}`); - const adapterId = packagedCapability.adapterId; + if (packagedCapability.schemaVersion === 1 && sdkAssetPaths.length === 0) { + throw new Error(`Packaged schema-1 coding plugin has no SDK assets: ${packageName}`); + } + const adapterId = packagedCapability.schemaVersion === 2 + ? packagedCapability.pluginId + : packagedCapability.adapterId; const tools = Array.isArray(packagedCapability.tools) ? packagedCapability.tools : []; const toolNames = tools.map((tool) => tool?.name); if (typeof adapterId !== 'string' || adapterId.length === 0 || tools.length === 0 diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 8b39b9d..699d0cd 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -1,8 +1,12 @@ import type { CapabilityResultV1 } from './data-service'; +import type { ModelToolDetailsV1 } from './model-tools'; +import type { DevicePackageToolDetailsV1 } from './device-packages'; export type { CapabilityBillingReceiptV1, CapabilityResultV1 } from './data-service'; +export type { ModelToolDetailsV1 } from './model-tools'; +export type { DevicePackageToolDetailsV1 } from './device-packages'; -export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; +export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max'; export interface ProductModelRef { accountId: string; @@ -220,6 +224,8 @@ export type KnownToolDetails = | GameAssetsDetailsV1 | RuntimeContextDetailsV1 | CapabilityResultV1 + | ModelToolDetailsV1 + | DevicePackageToolDetailsV1 | SubagentDetailsV1; export interface ConversationToolNode { diff --git a/shared/coding-conversation-product-tool-protocol.ts b/shared/coding-conversation-product-tool-protocol.ts index 14c6905..e6f1788 100644 --- a/shared/coding-conversation-product-tool-protocol.ts +++ b/shared/coding-conversation-product-tool-protocol.ts @@ -8,6 +8,14 @@ import type { RuntimeContextDetailsV1, TaskStateDetailsV1, } from './coding-conversation-contracts'; +import type { ModelToolDetailsV1, ModelWebSearchErrorCode } from './model-tools'; +import { + DEVICE_PACKAGE_TOOL_NAMES, + type DevicePackageIndexV1, + type DevicePackageRecordV1, + type DevicePackageToolDetailsV1, + type InstallPreviewV1, +} from './device-packages'; import { DATA_SERVICE_PI_TOOL_NAMES } from './data-service'; const PRODUCT_TOOL_NAMES = new Set([ @@ -17,6 +25,8 @@ const PRODUCT_TOOL_NAMES = new Set([ 'task_state', 'changed_file', 'runtime_context', + 'web_search', + ...DEVICE_PACKAGE_TOOL_NAMES, ...DATA_SERVICE_PI_TOOL_NAMES, ]); @@ -192,6 +202,11 @@ function capabilityBilling(value: unknown): CapabilityBillingReceiptV1 | null { ? { mode: 'external_account', status: 'external' } : null; } + if (billing.mode === 'platform_metered' && billing.status === 'receipt_unavailable') { + return exactKeys(billing, ['mode', 'status']) + ? { mode: 'platform_metered', status: 'receipt_unavailable' } + : null; + } if (billing.mode !== 'platform_metered' || typeof billing.reserved_points !== 'string' || !CAPABILITY_DECIMAL_PATTERN.test(billing.reserved_points) || billing.reserved_points.length > 32) return null; @@ -234,6 +249,7 @@ function capabilityDetails(value: Record): CapabilityResultV1 | || value.schema !== 'makelore-capability.v1') return null; if (typeof value.plugin_id !== 'string' || value.plugin_id.length > 48 || !CAPABILITY_PLUGIN_ID_PATTERN.test(value.plugin_id) + || value.plugin_id === 'makelore.web-search' || !text(value.plugin_version, 64) || typeof value.capability_id !== 'string' || !CAPABILITY_ID_PATTERN.test(value.capability_id) || typeof value.operation !== 'string' || !CAPABILITY_OPERATION_PATTERN.test(value.operation) @@ -293,6 +309,186 @@ function capabilityDetails(value: Record): CapabilityResultV1 | }; } +const MODEL_WEB_SEARCH_ERROR_CONTRACT = Object.freeze({ + model_web_search_unsupported: { httpStatus: 400, retryable: false }, + model_context_changed: { httpStatus: 409, retryable: false }, + model_web_search_rate_limited: { httpStatus: 429, retryable: false }, + model_web_search_unavailable: { httpStatus: 503, retryable: true }, + model_web_search_invalid_result: { httpStatus: 502, retryable: false }, +} satisfies Record); + +function modelToolSource(value: unknown): { title: string; url: string } | null { + const source = record(value); + const title = text(source?.title, 240); + const rawUrl = text(source?.url, 2_048); + if (!source || !exactKeys(source, ['title', 'url']) || !title || !rawUrl) return null; + try { + const url = new URL(rawUrl); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) return null; + return { title, url: url.toString() }; + } catch { + return null; + } +} + +function modelToolDetails(value: Record): ModelToolDetailsV1 | null { + if (value.schema !== 'makelore-model-tool.v1' || value.tool !== 'web_search') return null; + const modelId = text(value.modelId, 256); + if (!modelId) return null; + if (value.status === 'succeeded') { + if (!exactKeys(value, [ + 'schema', 'tool', 'status', 'modelId', 'answer', 'sources', 'sourceMode', + ])) return null; + const answer = text(value.answer, 20_000); + if (!answer || !Array.isArray(value.sources) || value.sources.length > 20 + || (value.sourceMode !== 'structured' && value.sourceMode !== 'inline-or-structured')) return null; + const sources = value.sources.map(modelToolSource); + if (sources.some((source) => source === null) + || (value.sourceMode === 'structured' && sources.length === 0)) return null; + return { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId, + answer, + sources: sources as Array<{ title: string; url: string }>, + sourceMode: value.sourceMode, + }; + } + if (value.status !== 'failed' + || !exactKeys(value, ['schema', 'tool', 'status', 'modelId', 'error'])) return null; + const error = record(value.error); + const code = error?.code; + const contract = typeof code === 'string' + ? MODEL_WEB_SEARCH_ERROR_CONTRACT[code as ModelWebSearchErrorCode] + : undefined; + const message = text(error?.message, 2_000); + if (!error || !contract || !message + || !exactKeys(error, ['code', 'message', 'httpStatus', 'retryable']) + || error.httpStatus !== contract.httpStatus + || error.retryable !== contract.retryable) return null; + return { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'failed', + modelId, + error: { + code: code as ModelWebSearchErrorCode, + message, + httpStatus: contract.httpStatus as 400 | 409 | 429 | 502 | 503, + retryable: contract.retryable, + }, + }; +} + +function deviceSkillEntries(value: unknown): DevicePackageRecordV1['skillEntries'] | null { + if (!Array.isArray(value) || value.length > 100) return null; + const entries: Array<{ id: string; entryPath: string }> = []; + for (const candidate of value) { + const item = record(candidate); + const id = text(item?.id, 128); + const entryPath = text(item?.entryPath, 1_024); + if (!item || !exactKeys(item, ['id', 'entryPath']) || !id || !entryPath) return null; + entries.push({ id, entryPath }); + } + return entries; +} + +function devicePackageKind(value: unknown): DevicePackageRecordV1['kind'] | null { + return value === 'skill-only' || value === 'pi-extension' || value === 'mixed' ? value : null; +} + +function devicePackagePreview(value: unknown): InstallPreviewV1 | null { + const preview = record(value); + if (!preview || !exactKeys(preview, [ + 'schemaVersion', 'planId', 'expiresAt', 'requestedSource', 'resolvedSource', + 'packageId', 'displayName', 'resolvedVersion', 'kind', 'skillEntries', + 'extensionEntries', 'includesExecutableCode', 'ignoredLifecycleScripts', 'warnings', + 'scope', + ]) || preview.schemaVersion !== 1 || preview.scope !== 'device-parent-workers' + || typeof preview.includesExecutableCode !== 'boolean') return null; + const planId = text(preview.planId, 128); + const expiresAt = text(preview.expiresAt, 64); + const requestedSource = text(preview.requestedSource, 2_048); + const resolvedSource = text(preview.resolvedSource, 4_096); + const packageId = text(preview.packageId, 128); + const displayName = text(preview.displayName, 240); + const resolvedVersion = text(preview.resolvedVersion, 128); + const kind = devicePackageKind(preview.kind); + const skillEntries = deviceSkillEntries(preview.skillEntries); + const extensionEntries = strings(preview.extensionEntries, 100); + const ignoredLifecycleScripts = strings(preview.ignoredLifecycleScripts, 20); + const warnings = strings(preview.warnings, 20); + if (!planId || !expiresAt || !requestedSource || !resolvedSource || !packageId + || !displayName || !resolvedVersion || !kind || !skillEntries || !extensionEntries + || !ignoredLifecycleScripts || !warnings) return null; + return { + schemaVersion: 1, planId, expiresAt, requestedSource, resolvedSource, packageId, + displayName, resolvedVersion, kind, skillEntries, extensionEntries, + includesExecutableCode: preview.includesExecutableCode, + ignoredLifecycleScripts, warnings, scope: 'device-parent-workers', + }; +} + +function devicePackageRecord(value: unknown): DevicePackageRecordV1 | null { + const item = record(value); + if (!item || !exactKeys(item, [ + 'schemaVersion', 'packageId', 'displayName', 'resolvedVersion', 'source', 'kind', + 'skillEntries', 'extensionEntries', 'enabled', 'confirmedExecutableCode', 'installedAt', + ]) || item.schemaVersion !== 1 || typeof item.enabled !== 'boolean' + || typeof item.confirmedExecutableCode !== 'boolean') return null; + const source = record(item.source); + const packageId = text(item.packageId, 128); + const displayName = text(item.displayName, 240); + const resolvedVersion = text(item.resolvedVersion, 128); + const kind = devicePackageKind(item.kind); + const installedAt = text(item.installedAt, 64); + const skillEntries = deviceSkillEntries(item.skillEntries); + const extensionEntries = strings(item.extensionEntries, 100); + if (!source || !exactKeys(source, ['kind', 'requested', 'resolved']) + || !['npm', 'git', 'file'].includes(String(source.kind)) + || !packageId || !displayName || !resolvedVersion || !kind || !installedAt + || !skillEntries || !extensionEntries) return null; + const requested = text(source.requested, 2_048); + const resolved = text(source.resolved, 4_096); + if (!requested || !resolved) return null; + return { + schemaVersion: 1, packageId, displayName, resolvedVersion, + source: { kind: source.kind as DevicePackageRecordV1['source']['kind'], requested, resolved }, + kind, skillEntries, extensionEntries, enabled: item.enabled, + confirmedExecutableCode: item.confirmedExecutableCode, installedAt, + }; +} + +function devicePackageIndex(value: unknown): DevicePackageIndexV1 | null { + const index = record(value); + if (!index || !exactKeys(index, ['schemaVersion', 'generation', 'packages']) + || index.schemaVersion !== 1 || !Number.isSafeInteger(index.generation) + || (index.generation as number) < 0 || !Array.isArray(index.packages) + || index.packages.length > 200) return null; + const packages = index.packages.map(devicePackageRecord); + if (packages.some((item) => item === null)) return null; + return { schemaVersion: 1, generation: index.generation as number, packages: packages as DevicePackageRecordV1[] }; +} + +function devicePackageDetails(value: Record): DevicePackageToolDetailsV1 | null { + if (value.schema !== 'makelore-device-package.v1' || value.success !== true + || !['prepare', 'commit', 'list', 'set_enabled', 'uninstall'].includes(String(value.operation))) return null; + if (value.operation === 'prepare') { + if (!exactKeys(value, ['schema', 'operation', 'success', 'preview'])) return null; + const preview = devicePackagePreview(value.preview); + return preview ? { schema: 'makelore-device-package.v1', operation: 'prepare', success: true, preview } : null; + } + if (!exactKeys(value, ['schema', 'operation', 'success', 'index'])) return null; + const index = devicePackageIndex(value.index); + return index ? { + schema: 'makelore-device-package.v1', + operation: value.operation as Exclude, + success: true, + index, + } : null; +} + export function productToolDetails(value: unknown): Exclude | null { const details = record(value); if (!details) return null; @@ -305,6 +501,8 @@ export function productToolDetails(value: unknown): Exclude { diff --git a/shared/device-packages.ts b/shared/device-packages.ts new file mode 100644 index 0000000..73df949 --- /dev/null +++ b/shared/device-packages.ts @@ -0,0 +1,74 @@ +export type DevicePackageSourceKind = 'npm' | 'git' | 'file'; +export type DevicePackageKind = 'skill-only' | 'pi-extension' | 'mixed'; + +export interface DevicePackageSkillEntry { + id: string; + entryPath: string; +} + +export interface InstallPreviewV1 { + schemaVersion: 1; + planId: string; + expiresAt: string; + requestedSource: string; + resolvedSource: string; + packageId: string; + displayName: string; + resolvedVersion: string; + kind: DevicePackageKind; + skillEntries: readonly DevicePackageSkillEntry[]; + extensionEntries: readonly string[]; + includesExecutableCode: boolean; + ignoredLifecycleScripts: readonly string[]; + warnings: readonly string[]; + scope: 'device-parent-workers'; +} + +export interface DevicePackageRecordV1 { + schemaVersion: 1; + packageId: string; + displayName: string; + resolvedVersion: string; + source: Readonly<{ + kind: DevicePackageSourceKind; + requested: string; + resolved: string; + }>; + kind: DevicePackageKind; + skillEntries: readonly DevicePackageSkillEntry[]; + extensionEntries: readonly string[]; + enabled: boolean; + confirmedExecutableCode: boolean; + installedAt: string; +} + +export interface DevicePackageIndexV1 { + schemaVersion: 1; + generation: number; + packages: readonly DevicePackageRecordV1[]; +} + +export type DevicePackageToolOperation = + | 'prepare' + | 'commit' + | 'list' + | 'set_enabled' + | 'uninstall'; + +export const DEVICE_PACKAGE_TOOL_NAMES = Object.freeze([ + 'local_package_prepare', + 'local_package_commit', + 'local_package_list', + 'local_package_set_enabled', + 'local_package_uninstall', +] as const); + +export type DevicePackageToolName = typeof DEVICE_PACKAGE_TOOL_NAMES[number]; + +export type DevicePackageToolDetailsV1 = Readonly<{ + schema: 'makelore-device-package.v1'; + operation: DevicePackageToolOperation; + success: true; + preview?: InstallPreviewV1; + index?: DevicePackageIndexV1; +}>; diff --git a/shared/imported-model-profile.ts b/shared/imported-model-profile.ts index f74892d..4afe531 100644 --- a/shared/imported-model-profile.ts +++ b/shared/imported-model-profile.ts @@ -1,6 +1,37 @@ export type ImportedModelModality = 'text' | 'audio' | 'image' | 'pdf'; export type ImportedVisionTokenEstimator = 'qwen-32px-grid'; -export type ImportedThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high'; +export type ImportedThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max'; +export const IMPORTED_REASONING_EFFORTS = ['low', 'high', 'max'] as const; +export type ImportedReasoningEffort = (typeof IMPORTED_REASONING_EFFORTS)[number]; + +export type ImportedModelWebSearchCapability = Readonly<{ + schemaVersion: 1; + adapter: 'openai-responses' | 'bailian-responses' | 'bailian-chat-completions'; + supportsForcedSearch: true; + sourceMode: 'structured' | 'inline-or-structured'; + billingAuthority: 'model-request'; +}>; + +export interface ImportedModelCapability { + reasoningEfforts: ImportedReasoningEffort[]; + reasoningCanDisable: boolean; + webSearch?: ImportedModelWebSearchCapability; +} + +export type ImportedModelCapabilities = Record; + +export function thinkingLevelMapForImportedModelCapability( + capability: ImportedModelCapability, +): Partial> { + return { + ...(capability.reasoningCanDisable ? {} : { off: null }), + minimal: null, + low: capability.reasoningEfforts.includes('low') ? 'low' : null, + medium: null, + high: capability.reasoningEfforts.includes('high') ? 'high' : null, + max: capability.reasoningEfforts.includes('max') ? 'max' : null, + }; +} export interface ImportedPiModelProfile { reasoning: boolean; @@ -62,14 +93,15 @@ export function getImportedModelProfile(rawModelId: string): ImportedModelProfil pi: { reasoning: true, thinkingLevelMap: { - off: null, minimal: null, - low: null, + low: 'low', medium: null, high: 'high', + max: 'max', }, compat: { thinkingFormat: 'deepseek', + supportsReasoningEffort: true, requiresReasoningContentOnAssistantMessages: true, }, }, diff --git a/shared/model-tools.ts b/shared/model-tools.ts new file mode 100644 index 0000000..d5fbde9 --- /dev/null +++ b/shared/model-tools.ts @@ -0,0 +1,36 @@ +export type ModelWebSearchErrorCode = + | 'model_web_search_unsupported' + | 'model_context_changed' + | 'model_web_search_rate_limited' + | 'model_web_search_unavailable' + | 'model_web_search_invalid_result'; + +export type ModelWebSearchSourceV1 = Readonly<{ + title: string; + url: string; +}>; + +export type ModelWebSearchSuccessV1 = Readonly<{ + schema: 'makelore-model-tool.v1'; + tool: 'web_search'; + status: 'succeeded'; + modelId: string; + answer: string; + sources: ReadonlyArray; + sourceMode: 'structured' | 'inline-or-structured'; +}>; + +export type ModelWebSearchFailureV1 = Readonly<{ + schema: 'makelore-model-tool.v1'; + tool: 'web_search'; + status: 'failed'; + modelId: string; + error: Readonly<{ + code: ModelWebSearchErrorCode; + message: string; + httpStatus: 400 | 409 | 429 | 502 | 503; + retryable: boolean; + }>; +}>; + +export type ModelToolDetailsV1 = ModelWebSearchSuccessV1 | ModelWebSearchFailureV1; diff --git a/shared/user-model-config.ts b/shared/user-model-config.ts index 9707b81..2d7efd0 100644 --- a/shared/user-model-config.ts +++ b/shared/user-model-config.ts @@ -1,3 +1,11 @@ +import { + IMPORTED_REASONING_EFFORTS, + type ImportedModelCapabilities, + type ImportedModelCapability, + type ImportedModelWebSearchCapability, + type ImportedReasoningEffort, +} from './imported-model-profile'; + export const NIANCODE_USER_MODEL_ACCOUNT_ID = 'niancode-user-models'; export const NIANCODE_USER_MODEL_ACCOUNT_LABEL = 'Makelore Models'; @@ -23,6 +31,94 @@ export function normalizeImportedUserModelId(rawModel: string): string { : trimmed; } +function normalizeImportedModelCapability(value: unknown): ImportedModelCapability | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const rawEfforts = record.reasoning_efforts ?? record.reasoningEfforts; + const reasoningCanDisable = record.reasoning_can_disable ?? record.reasoningCanDisable; + if (!Array.isArray(rawEfforts) || typeof reasoningCanDisable !== 'boolean') return null; + const reasoningEfforts = IMPORTED_REASONING_EFFORTS.filter((effort) => ( + rawEfforts.some((candidate) => candidate === effort) + )) as ImportedReasoningEffort[]; + if (reasoningEfforts.length === 0 && rawEfforts.length > 0) return null; + const webSearch = normalizeImportedModelWebSearchCapability( + record.web_search ?? record.webSearch, + ); + return { + reasoningEfforts, + reasoningCanDisable, + ...(webSearch ? { webSearch } : {}), + }; +} + +function normalizeImportedModelWebSearchCapability( + value: unknown, +): ImportedModelWebSearchCapability | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const schemaVersion = record.schema_version ?? record.schemaVersion; + const rawAdapter = record.adapter; + const supportsForcedSearch = record.supports_forced_search ?? record.supportsForcedSearch; + const rawSourceMode = record.source_mode ?? record.sourceMode; + const rawBillingAuthority = record.billing_authority ?? record.billingAuthority; + const adapters: Record = { + openai_responses: 'openai-responses', + 'openai-responses': 'openai-responses', + bailian_responses: 'bailian-responses', + 'bailian-responses': 'bailian-responses', + bailian_chat_completions: 'bailian-chat-completions', + 'bailian-chat-completions': 'bailian-chat-completions', + }; + const sourceModes: Record = { + structured: 'structured', + inline_or_structured: 'inline-or-structured', + 'inline-or-structured': 'inline-or-structured', + }; + const billingAuthorities: Record = { + model_request: 'model-request', + 'model-request': 'model-request', + }; + const adapter = typeof rawAdapter === 'string' ? adapters[rawAdapter] : undefined; + const sourceMode = typeof rawSourceMode === 'string' ? sourceModes[rawSourceMode] : undefined; + const billingAuthority = typeof rawBillingAuthority === 'string' + ? billingAuthorities[rawBillingAuthority] + : undefined; + if ( + schemaVersion !== 1 + || !adapter + || supportsForcedSearch !== true + || !sourceMode + || !billingAuthority + ) { + return null; + } + return { + schemaVersion: 1, + adapter, + supportsForcedSearch: true, + sourceMode, + billingAuthority, + }; +} + +export function normalizeImportedModelCapabilities( + value: unknown, + modelIds?: readonly string[], +): ImportedModelCapabilities | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const allowedModelIds = modelIds + ? new Set(modelIds.map(normalizeImportedUserModelId)) + : null; + const result: ImportedModelCapabilities = {}; + for (const [rawModelId, rawCapability] of Object.entries(value as Record)) { + const modelId = normalizeImportedUserModelId(rawModelId); + if (!modelId || (allowedModelIds && !allowedModelIds.has(modelId))) continue; + const capability = normalizeImportedModelCapability(rawCapability); + if (capability && !result[modelId]) result[modelId] = capability; + } + return Object.keys(result).length > 0 ? result : undefined; +} + export function selectUserModelRuntimeAccounts( accounts: T[], ): T[] { diff --git a/src/components/layout/TitleBar.tsx b/src/components/layout/TitleBar.tsx index 16dd735..f271404 100644 --- a/src/components/layout/TitleBar.tsx +++ b/src/components/layout/TitleBar.tsx @@ -17,6 +17,9 @@ import type { SidebarPeekSource } from './sidebar-peek'; type SidebarPeekChange = (open: boolean, source: SidebarPeekSource) => void; +// Three 44px buttons plus the control container's 8px padding on each side. +const WINDOWS_TITLEBAR_CONTROLS_WIDTH = 148; + type TitleBarProps = { integrated?: boolean; workspaceLayout?: boolean; @@ -47,6 +50,7 @@ function ProductTitleBar({ overlay = false, pageTitle, children, + windowControlsWidth = 0, nativeTrafficLights = false, sidebarPeekOpen = false, onSidebarPeekChange, @@ -56,6 +60,7 @@ function ProductTitleBar({ overlay?: boolean; pageTitle?: string; children?: React.ReactNode; + windowControlsWidth?: number; nativeTrafficLights?: boolean; sidebarPeekOpen?: boolean; onSidebarPeekChange?: SidebarPeekChange; @@ -119,7 +124,7 @@ function ProductTitleBar({ overlay ? 'bg-transparent' : 'bg-background', )} style={{ - right: '0px', + right: `${windowControlsWidth}px`, }} > {children} @@ -257,7 +263,15 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, pageTitle, side }; return ( - +
+ ); + } + if (details.schema === 'makelore-model-tool.v1') { + return ( +
+

模型联网搜索 · {details.modelId}

+ {details.status === 'succeeded' ? ( +

+ {details.sources.length > 0 + ? `已返回 ${details.sources.length} 个来源` + : '提供方未返回结构化来源'} +

+ ) : ( +

{details.error.code} · {details.error.message}

+ )} +
+ ); + } + if (details.schema === 'makelore-device-package.v1') { + const subject = details.preview?.displayName + ?? details.index?.packages.at(-1)?.displayName + ?? '本机包'; + return ( +
+

本机插件 · {subject}

+

+ {details.operation === 'prepare' + ? `等待确认${details.preview?.includesExecutableCode ? ' · 包含可执行扩展' : ''}` + : `操作完成 · 共 ${details.index?.packages.length ?? 0} 个本机包`} +

); } @@ -811,6 +860,16 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu ? `${details.operation} · 成功` : `${details.operation} · ${details.error ?? '请求失败'}`; } + if (details.schema === 'makelore-model-tool.v1') { + return details.status === 'succeeded' + ? `联网搜索 · ${details.sources.length} 个来源` + : `联网搜索 · ${details.error.code}`; + } + if (details.schema === 'makelore-device-package.v1') { + return details.operation === 'prepare' + ? `本机插件 · 等待确认安装 ${details.preview?.displayName ?? ''}`.trim() + : `本机插件 · ${details.operation}`; + } const task = details.tasks.find((candidate) => ( candidate.status === 'running' || candidate.status === 'queued' )) ?? details.tasks.at(-1); diff --git a/src/pages/ImageCanvas/CreativeChoiceGrid.tsx b/src/pages/ImageCanvas/CreativeChoiceGrid.tsx new file mode 100644 index 0000000..23e0beb --- /dev/null +++ b/src/pages/ImageCanvas/CreativeChoiceGrid.tsx @@ -0,0 +1,96 @@ +import { Check, Sparkles, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { YouthActiveChoice } from './youth-form-projection'; + +export type CreativeChoiceGridProps = { + choice: YouthActiveChoice; + disabled?: boolean; + onSelect: (optionId: string) => void; + onReject?: () => void; +}; + +/** + * A small, keyboard-friendly surface for the one decision the creator needs + * to make next. The server-provided option label stays visible verbatim (so + * options such as “你帮我选” remain useful), while the first option gets a + * gentle recommendation marker. + */ +export function CreativeChoiceGrid({ + choice, + disabled = false, + onSelect, + onReject, +}: CreativeChoiceGridProps) { + if (choice.options.length === 0) return null; + + return ( +
+
+
+
+
+

{choice.title}

+

{choice.body}

+
+
+ +
+ 选择一个创作方向 + {choice.options.map((option, index) => { + const descriptionId = `${choice.id}-${option.id}-description`; + return ( + + ); + })} +
+ + {onReject && ( + + )} +
+ ); +} diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index e33f593..0d14535 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -1,16 +1,17 @@ import { useEffect, useMemo, useRef } from 'react'; -import { Check, Loader2, MessageSquareText, Send, Sparkles, X } from 'lucide-react'; +import { Loader2, MessageSquareText, Send, Sparkles } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignWorkspace } from '../../../shared/image-workspace'; +import { CreativeChoiceGrid } from './CreativeChoiceGrid'; const STARTER_MESSAGES = [ - '帮我做一张新品发布主视觉,先问我最关键的问题', - '我有一个模糊想法,请帮我整理成专业的图片设计方案', - '我要做一段产品短视频,请先规划叙事和分镜', + '画一只会做饭的机器人', + '把我的涂鸦变成一段动画', + '做一张社团活动海报', ]; export function DesignConversationPane({ workspace }: { workspace: DesignWorkspace }) { @@ -28,6 +29,10 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa const submittingChat = Object.values(pendingOperations).some( (operation) => operation.status === 'submitting' && operation.command.kind === 'apply_input', ); + const activeDecisionPrompt = workspace.form.decisionPrompts + .find((prompt) => prompt.options.length > 0) + ?? workspace.form.decisionPrompts[0] + ?? null; useEffect(() => { const scrollAnchor = scrollAnchorRef.current; @@ -38,8 +43,8 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa const submit = () => { if (!chatDraft.trim() || submittingChat) return; - void sendChat().catch((error) => { - toast.error(error instanceof Error ? error.message : '消息发送失败'); + void sendChat().catch(() => { + toast.error('消息没有发出去,请再试一次'); }); }; @@ -51,10 +56,10 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
-

与设计 Agent 对话

+

一起做作品

- 你说的每句话都会先进入设计规格,再由服务端编译成专业生成方案。 + 先说一个想法就好,我会一次只问一个重要问题。

@@ -64,16 +69,16 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa
-

从自然语言开始

+

你想创作什么?

- 不需要一次把需求说完整。Agent 会把信息写入右侧表单,并把关键取舍留给你确认。 + 可以说一个想法,也可以先放一张图片。不用一次想完整。

{STARTER_MESSAGES.map((message) => (
))} - {workspace.form.decisionPrompts.map((prompt) => ( -
-
-
- -
-
-

{prompt.title}

-

{prompt.body}

-
-
-
- {prompt.options.map((option) => ( - - ))} -
- -
- ))} + {activeDecisionPrompt && ( + { + void resolveDecisionPrompt(activeDecisionPrompt.id, 'select', optionId).catch(() => { + toast.error('这个选择没有保存,请再试一次'); + }); + }} + onReject={() => { + void resolveDecisionPrompt(activeDecisionPrompt.id, 'reject').catch(() => { + toast.error('这个选择没有保存,请再试一次'); + }); + }} + /> + )}
@@ -170,11 +140,12 @@ export function DesignConversationPane({ workspace }: { workspace: DesignWorkspa submittingChat && 'opacity-80', )}>