diff --git a/.scratch/dsh-managed-presets/implementation-report.md b/.scratch/dsh-managed-presets/implementation-report.md new file mode 100644 index 0000000..d66e182 --- /dev/null +++ b/.scratch/dsh-managed-presets/implementation-report.md @@ -0,0 +1,143 @@ +# DSH compatible presets implementation report + +## Scope + +- Repository: `guionai/web` +- Branch: `dsh-managed-presets` +- Fixed point: `1bb01fd` (`main`) +- Final implementation commit: `3d7d4a1` (`refactor(dsh): simplify compatible preset sync`) +- Delivery boundary: the complete `dsh-managed-presets` spec and tickets 01–04. + +## Outcome + +Guion's DSH bundle now owns the complete research surface: +`web_search`, `web_fetch`, `web_links`, `web_docs`, and +`web_source_search`. The official Web registry/provider integration and +dependency are gone. The Guion implementations retain the selected search +providers, namespaced credentials, fetch navigation/rendering options, +cancellation, bounded output, and conditional Kepos tools. + +The bundle patch disables the official Web stack and hides shipped presets. +It exposes only the user preset root, with `standard` as the default. The +effective Guion schemas remain: + +- `web_search`: required `queries` +- `web_fetch`: required `url`; optional `mode`, `section_id`, `render`, and + `waitMs` + +## Compatible preset workflow + +`web dsh sync` creates or refreshes `standard`, `ptc`, `cordis`, and `minimal` +under the DSH user preset root. Each copy comes from the installed official +`@deepseek-ai/dsh-agent-presets` package with the official `tool-web` entry +removed structurally. + +There is no Guion marker file or persistent ownership metadata. For each +same-ID directory, sync compares the complete tree with two snapshots: + +- an exact current compatible copy is safe to refresh; +- an exact official copy is safe to convert; +- any other content is treated as user-modified and requires interactive + confirmation, or `--yes` in automation. + +Non-interactive sync refuses modified same-ID directories unless `--yes` is +present. Unrelated user presets are untouched. Replacement is staged per +preset, with a local backup restored if installation fails; there is no global +four-preset transaction or speculative race-hook machinery. + +`web dsh doctor` is read-only and reports each compatible preset as `ok`, +`missing`, `stale` (an exact official copy), or `conflict` (any other content). + +## Documentation + +The workflow and the requirement to sync before selecting a compatible preset +are documented in the root README and `packages/dsh-web/README.md`. +`CONTEXT.md` defines the vocabulary, ADR 0004 records the ownership decision, +and `AGENTS.md` records the test-state and real-Linux verification rules. + +## Verification + +Final local checks passed: + +- `pnpm test` — 20 files, 161 tests +- `pnpm typecheck` +- `pnpm build` — all four packages +- `pnpm test:pack` — Web, Pi, and DSH package smoke checks +- `pnpm format:check` +- `pnpm test:release` +- `git diff --check` + +The packed Web smoke no longer contains a fake DSH installation or a second +sync/doctor test graph. Runtime discovery is covered by focused temporary +filesystem tests, including a standard `node_modules/.bin/dsh` shim. + +The real integration check ran on `nuc-kep` from the existing checkout +`/home/neil/code/projects/guionai/web`. `og pull` first fast-forwarded its +current `main`; because `og pull` only fetches the current branch, the new +remote feature ref was then fetched and checked out. The checkout was built +with its installed pnpm, and the built Web CLI called the NUC's installed +`dsh` executable. All mutable DSH state was isolated under: + +```text +/tmp/guion-dsh-checkout.q3UxVE/dsh-home +``` + +The disposable profile linked the checkout's current `@guionai/dsh-web` +package. No live yuki profile, credentials, preset root, or service was read or +changed. + +Observed results: + +```text +DSH compatible presets created all four presets from @deepseek-ai/dsh-agent-presets@0.1.2-rc.1. +DSH doctor: OK +- standard: ok +- ptc: ok +- cordis: ok +- minimal: ok +``` + +The same run then exercised the overwrite policy: + +1. Replacing `standard` with the exact installed official preset was accepted + and converted without a prompt. +2. Adding a user edit to `ptc` made non-interactive sync exit with status 1: + `refusing to overwrite modified same-id preset ptc; rerun interactively or pass --yes`. +3. Re-running with `--yes` refreshed all four presets, after which doctor was + fully green again. + +The same disposable profile was then composed by the installed official Loader +with a read-only probe bundle. The reconciled config contained +`includeShippedRoot: false`, `includeUserRoot: true`, and `default: standard`; +the installed `@guionai/dsh-web` link resolved exactly to the NUC checkout. +The Loader booted successfully and was stopped immediately after the probe: + +```text +standard user rows=26 broken=absent tool-web=0 +ptc user rows=27 broken=absent tool-web=0 +minimal user rows=8 broken=absent tool-web=0 +cordis user rows=27 broken=absent tool-web=0 +web_fetch properties=url,mode,section_id,render,waitMs required=url +web_search properties=queries required=queries +``` + +The probe output is `/tmp/guion-dsh-checkout.q3UxVE/probe.json`. The disposable +Loader process was confirmed stopped, and its token-bearing launch URL was not +recorded. + +Vitest emits the existing non-failing missing source-map warning from the DSH +primitives package. + +## Size + +Against `1bb01fd`, excluding the lockfile and this report, the final diff is +2,144 additions and 521 deletions (2,665 changed lines). The simplified sync +removed 901 lines and added 392 relative to the previously reviewed branch: +the marker protocol, global transaction, injected rename hooks, shim-text +parser, and packed fake-DSH smoke were deleted. + +## Acceptance result + +The whole spec is implemented and verified. The final workflow uses content +comparison plus explicit confirmation, keeps upstream DSH unchanged, and uses +the NUC's real CLI for Linux integration verification. diff --git a/AGENTS.md b/AGENTS.md index f5595e2..20585b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,4 +6,17 @@ Repository CI and local tests target Linux. Treat Windows behavior as best-effor ## DSH rc.1 Loader validation -For profile-local packaged plugins on DSH `0.1.2-rc.1` with Node 24, install the official rc.1 CLI into a test-owned disposable directory and validate its real installed graph through the Node entrypoint with internals enabled: `node --expose-internals /node_modules/@deepseek-ai/dsh/lib/bin.js ...`. Keep `DSH_HOME` test-owned and disposable, do not use a deployed runtime or live credentials, and do not add custom runtime overrides or compatibility layers. +For profile-local packaged plugins on DSH `0.1.2-rc.1` with Node 24, validate on the NUC through its installed official `dsh` CLI and real Loader. Keep `DSH_HOME` and every generated profile test-owned and disposable; the installed CLI and preset package are read-only inputs. Never use live profiles, presets, credentials, overrides, or services, and do not add custom runtime overrides or compatibility layers. + +## Managed DSH presets + +The supported preset workflow is explicit: run `web dsh sync` before installing +or activating `@guionai/dsh-web`, then run `web dsh doctor` and require a zero +exit status. Sync compares `standard`, `ptc`, `cordis`, and `minimal` with the +official and compatible trees; modified same-id content requires interactive +confirmation or `--yes`. It must not mutate shipped presets, unrelated user +presets, credentials, or sessions. Tests use fixture source trees and +disposable `DSH_HOME` directories. End-to-end validation runs from the existing +NUC checkout, built on the NUC, using its installed official DSH CLI and a +disposable `DSH_HOME`. Do not transfer or validate a Web CLI packed on macOS. +Record the paths and results in the implementation report. diff --git a/CONTEXT.md b/CONTEXT.md index 7c22753..90c8b3d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -71,3 +71,42 @@ The `/api/v1/web` versioned path namespace for the HTTP Service's research operations when exposed through FlickNote Gateway. It identifies Guion Web as the owning service without changing its operation contracts. _Avoid_: Bare `/v1` API, generic web route + +**DSH Research Surface**: +The Guion-owned model-facing DSH tool suite: `web_search`, `web_fetch`, +`web_links`, `web_docs`, and `web_source_search`, plus `web_weather`, +`web_sports`, `web_finance`, and `web_time` while Kepos Bridge is selected. +It is registered globally by `@guionai/dsh-web`; native and PTC presentation +modes consume the same registrations. +_Avoid_: Official Web suite, scoped Web row + +**Compatible DSH Preset**: +A DSH preset that omits its entire scoped `tool-web` row and therefore inherits +the global DSH Research Surface from Guion. The `web dsh sync` command creates +four generated compatible copies with the same ids as the official shipped +`standard`, `ptc`, `cordis`, and `minimal` presets. The DSH bundle hides the +shipped root while retaining the user root, so these copies become active and +ordinary user presets such as Yuki remain visible. +_Avoid_: Unmanaged copied preset, partially compatible preset + +**Official Shipped DSH Preset**: +A preset installed by `@deepseek-ai/dsh-agent-presets` beside the DSH runtime. +It is deployment-owned, may include the scoped official `tool-web` row, and is +never edited by Guion. The bundle sets `includeShippedRoot: false` because DSH +shipped entries win same-id duplicates. +_Avoid_: Managed preset, user preset + +**Guion-Compatible DSH Preset**: +A full snapshot under `${DSH_HOME:-$HOME/.dsh}/.agent-presets` generated by +`web dsh sync` from the installed official rc.1 package. Sync recognizes it by +comparing its complete contents with the expected transformed official tree; +no ownership marker is stored. A different same-id directory requires explicit +interactive confirmation or `--yes`. `web dsh doctor` verifies that all four +snapshots are current, complete, and free of scoped `tool-web` rows. +_Avoid_: Hand-edited output, shipped preset + +**Ordinary DSH User Preset**: +A user-created directory in the DSH user preset root. It remains visible when +the bundle hides the shipped root. A same-id preset is overwritten only after +interactive confirmation or an explicit `web dsh sync --yes`. +_Avoid_: Generated compatible preset, shipped preset diff --git a/README.md b/README.md index 8be0eed..81cc7bd 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ HTTP service, Pi extension, and DeepSeek Harness (DSH) integration: HTTP HTML-to-Markdown extraction and explicit browser rendering for client-rendered pages on supported hosts. +> **DSH setup note:** Run `web dsh sync` before installing the DSH bundle and +> `web dsh doctor` afterward. The workflow creates compatible copies of the +> familiar stock preset ids and hides their official shipped duplicates while +> preserving ordinary user presets. + ## Install and configure Node.js 20 or later is required. `@guionai/web` exposes its `web` executable, @@ -202,21 +207,45 @@ the presence of its key alone. ## DSH -Install the DSH bundle in the existing Web profile: +The bundle uses the familiar stock preset ids, but its compatible copies are +owned by Guion in the DSH user preset root. Synchronize those copies before +installing or activating the profile bundle: ```bash +web dsh sync dsh plugin --profile web add @guionai/dsh-web +web dsh doctor ``` -The included profile patch routes stock PTC web search through the selected Exa, -Brave, DeepSeek, or Kepos Bridge provider. Its settings UI stores provider selection and -the complete non-secret Kepos Bridge route (default -`http://codex-bridge.localhost:17480/codex/web-search`) and manages namespaced write-only -credentials, including a write-only DeepSeek API key. DeepSeek uses the same -provider picker/key workflow and exposes no DeepSeek endpoint field. Selecting Kepos Bridge additionally exposes `web_weather`, -`web_sports`, `web_finance`, and `web_time`; these tools are removed when another -provider is selected. Fetch, link discovery, documentation, and Sourcegraph tools -also run in-process. The host DSH packages and React are peers supplied by DSH. +`web dsh sync` reads the installed official +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates compatible +`standard`, `ptc`, `cordis`, and `minimal` copies. It removes only +the top-level official `tool-web` row from the first three; Minimal is copied +unchanged because it already omits that row. The command is idempotent and +compares existing same-id directories with both the official and compatible +trees. Exact matches are refreshed automatically. Modified same-id presets +require interactive confirmation; use `web dsh sync --yes` for an intentional +non-interactive overwrite. Run sync again after upgrading the supported DSH +runtime. The read-only doctor command reports missing, stale, and conflicting +copies and exits nonzero when the roster is not ready. + +The bundle's preset roster sets `includeShippedRoot: false`, +`includeUserRoot: true`, and `default: standard`. This hides all official +shipped duplicates while preserving Yuki and every other ordinary user +preset. Existing sessions, credentials, and deployed profiles are not +migrated automatically; activate or deploy the bundle separately after a +successful sync and doctor run. + +The profile patch disables the official DSH Web registry, search/fetch providers, +and `tool-web`, then registers Guion's complete DSH Research Surface directly. +Its settings UI stores provider selection and the complete non-secret Kepos +Bridge route (default `http://codex-bridge.localhost:17480/codex/web-search`) +and manages namespaced write-only credentials, including a write-only DeepSeek +API key. DeepSeek uses the same provider picker/key workflow and exposes no +DeepSeek endpoint field. Selecting Kepos Bridge additionally exposes +`web_weather`, `web_sports`, `web_finance`, and `web_time`; these tools are +removed when another provider is selected. The host DSH target is +`0.1.2-rc.1`; its packages and React are peers supplied by DSH. `web_fetch` uses HTTP rendering by default and can explicitly use `render: "browser"` with an integer `waitMs` on a host that supplies the optional executable. @@ -227,6 +256,15 @@ other adapters: input mode is `auto|full|tree` (default `auto`), and omitted or `web_links` uses the same explicit rendering contract and lists HTTP(S) anchors from the original page DOM. +Guion owns `web_search`, `web_fetch`, `web_links`, `web_docs`, and +`web_source_search` in both native and PTC presentation modes. `web_search` +accepts one to four trimmed queries, runs them concurrently, deterministically +merges partial successes, and reports total failure clearly. `web_fetch` +accepts `mode: "auto" | "full" | "tree"`, optional `section_id` with omitted +or `auto` mode, `render: "http" | "browser"`, and browser `waitMs` from 0 +through 30,000. `web_links` has the same rendering and wait contract. The +complete schemas are inherited by every compatible stock-equivalent preset. + ## Page-rendering modes `web fetch` has two renderers. `http` (the default) uses Node `fetch`, `linkedom`, diff --git a/docs/adr/0004-dsh-research-surface-owner.md b/docs/adr/0004-dsh-research-surface-owner.md new file mode 100644 index 0000000..0680f17 --- /dev/null +++ b/docs/adr/0004-dsh-research-surface-owner.md @@ -0,0 +1,56 @@ +# Make Guion the single owner of the DSH Research Surface + +## Status + +Accepted + +## Context + +Guion's DSH integration previously selected a Guion provider through the +official DSH Web registry while the official `tool-web` plugin supplied the +model-facing `web_search` and `web_fetch` surface. A scoped official row in a +preset could therefore change the effective tools between native and PTC +modes, and the profile carried duplicate provider and Web-suite ownership. + +## Decision + +This decision targets DeepSeek Harness `0.1.2-rc.1`. + +`@guionai/dsh-web` directly registers the complete DSH Research Surface: +`web_search`, `web_fetch`, `web_links`, `web_docs`, and `web_source_search`, +with the Kepos-only `web_weather`, `web_sports`, `web_finance`, and `web_time` +registrations enabled only while Kepos Bridge is selected. Search reads the +live Guion provider and namespaced credential for every execution. The plugin +does not register with or depend on the official DSH Web provider registry. + +The profile patch disables `dsh-web`, `web-search-deepseek`, `web-fetch-http`, +and `tool-web`, then mounts only the Guion host plugin with its credential, +settings, and tools services. The bundle also patches the official +`agent-presets` row with `includeShippedRoot: false`, `includeUserRoot: true`, +and `default: standard`. DSH prepends shipped presets and lets them win +same-id duplicates, so the shipped root must be hidden for compatible copies +to take effect. + +`web dsh sync` reads the installed official +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` tree and writes full snapshots of +`standard`, `ptc`, `cordis`, and `minimal` under the user preset root. It +removes only the expected top-level official `tool-web` row from the first +three. Sync compares existing directories with the official and compatible +trees, refreshes exact matches automatically, and requires interactive +confirmation or `--yes` before replacing modified same-id content. It never +edits the shipped package. `web dsh doctor` performs the corresponding +read-only checks. + +## Consequences + +Provider selection and credentials remain one live Guion settings surface, and +native and PTC calls receive the same registered tools. Official shipped +presets remain deployment-owned and untouched; their same-id compatible copies +are the supported path because the bundle hides the shipped root while +retaining Yuki and other ordinary user presets. Existing sessions, credentials, +and deployed profiles are not migrated automatically. Runtime upgrades require +an explicit sync, and deployment remains a separate operator action. + +The root and package READMEs, `CONTEXT.md`, and `AGENTS.md` are the only project +documents that describe this operator and agent workflow; no other project +documentation exposes a DSH preset contract that needs updating. diff --git a/packages/dsh-web/README.md b/packages/dsh-web/README.md index 114bcb7..ff91744 100644 --- a/packages/dsh-web/README.md +++ b/packages/dsh-web/README.md @@ -2,15 +2,51 @@ DeepSeek Harness 0.1.2-rc.1 Web bundle and browser settings client for Guion Web. -Install it into the existing Web profile: +Generate compatible stock-equivalent presets before installing or activating +the bundle in the existing Web profile: ```bash +web dsh sync dsh plugin --profile web add @guionai/dsh-web +web dsh doctor ``` -The package owns the Web profile patch and does not require a custom profile or -PTC preset. It leaves the root `tool-web` row disabled and routes stock PTC's -batched `web_search` through the Guion provider seam. +`web dsh sync` reads the installed official +`@deepseek-ai/dsh-agent-presets@0.1.2-rc.1` package and creates compatible copies +with the familiar `standard`, `ptc`, `cordis`, and `minimal` ids in +`${DSH_HOME:-$HOME/.dsh}/.agent-presets`. It removes only the top-level +official `tool-web` row from `standard`, `ptc`, and `cordis`; Minimal is copied +unchanged. Sync compares same-id directories with the official and compatible +trees and refreshes exact matches automatically. Modified same-id presets +require interactive confirmation; pass `--yes` only for an intentional +non-interactive overwrite. Run sync again after upgrading DSH. `doctor` is +read-only, reports missing/conflicting/stale copies, and exits nonzero until +all four are current. + +The bundle patch sets `includeShippedRoot: false`, `includeUserRoot: true`, +and `default: standard`. This hides the official shipped duplicates while +retaining Yuki and all other ordinary user presets. Existing sessions, +credentials, and deployed profiles are not migrated automatically; activate or +deploy the bundle only after sync and doctor succeed. + +The package owns the global DSH Research Surface and does not require a custom +profile. It directly registers `web_search`, `web_fetch`, `web_links`, +`web_docs`, and `web_source_search`; while Kepos Bridge is selected it also +registers `web_weather`, `web_sports`, `web_finance`, and `web_time`. Compatible +stock-equivalent presets omit the scoped `tool-web` row so both native and PTC +modes inherit these same global registrations. + +The profile patch disables the official DSH Web registry, official search and +fetch providers, and official `tool-web`; it does not load or depend on the +official `@deepseek-ai/dsh-web` package. + +The Guion schemas are complete and shared by every compatible preset: +`web_search` takes one to four trimmed queries and preserves concurrent, +deterministic partial results; `web_fetch` takes `mode: "auto" | "full" | +"tree"`, optional `section_id` with omitted/`auto` mode, and explicit +`render: "http" | "browser"` with browser `waitMs` from 0 through 30,000; +`web_links` has the same renderer contract. `web_docs` and +`web_source_search` retain their existing Context7 and Sourcegraph contracts. Provider selection is explicit and persists in the `guionai-web` settings namespace. Exa, Brave, and DeepSeek API keys use namespaced write-only DSH credentials; @@ -26,6 +62,14 @@ field in this card is for Kepos Bridge only. The route is a complete absolute `http:` or `https:` URL; credentials, query strings, and fragments are rejected and its path is used exactly as entered. +`web_search` accepts one to four trimmed, non-empty queries, starts valid +queries concurrently, interleaves successful results deterministically, and +keeps partial successes when one query fails. If every query fails it reports +the failed queries clearly. The selected provider and its credential are read +when each search executes, so the next call observes a settings change without +remounting a preset. Search output uses the same bounded model-facing text +conventions as the other Guion adapters. + The published package is a dual host/browser bundle. Its host and client artifacts, profile patch, and exact DSH `0.1.2-rc.1` peer contract are included in the npm package. Search, HTTP page rendering, page-link discovery, optional browser rendering, diff --git a/packages/dsh-web/cordis.patch.yml b/packages/dsh-web/cordis.patch.yml index 4ade833..cc9fd24 100644 --- a/packages/dsh-web/cordis.patch.yml +++ b/packages/dsh-web/cordis.patch.yml @@ -1,16 +1,30 @@ -# rc.1 stock Web has one configured search provider and no fetch provider. -# Replace only that complete service config; the PTC preset remains untouched. +# Guion owns the complete model-facing Research Surface. Disable every stock +# Web owner so no profile can register a second provider, fetch backend, or +# scoped tool suite beside it. - id: web - name: "@deepseek-ai/dsh-web" - config: - searchProvider: guionai-web-search + disabled: true + +- id: web-search-deepseek + disabled: true + +- id: web-fetch-http + disabled: true -# The root model-facing tool is intentionally disabled. PTC owns its native -# batched web_search surface and reaches the provider through ctx.web. - id: tool-web disabled: true +# DSH prepends its shipped preset root and lets it win duplicate ids by +# design. Guion-managed same-id copies therefore become active only when the +# shipped root is disabled; the user root remains enabled so Yuki and other +# ordinary user presets stay visible. +- id: agent-presets + name: "@deepseek-ai/dsh-agent-presets" + config: + includeShippedRoot: false + includeUserRoot: true + default: standard + - insert: - id: guionai-dsh-web name: "@guionai/dsh-web" - inject: [web, credentials, settings, tools] + inject: [credentials, settings, tools] diff --git a/packages/dsh-web/package.json b/packages/dsh-web/package.json index ec09ea9..64673ef 100644 --- a/packages/dsh-web/package.json +++ b/packages/dsh-web/package.json @@ -1,7 +1,7 @@ { "name": "@guionai/dsh-web", "version": "0.1.0", - "description": "DeepSeek Harness 0.1.2-rc.1 provider for Exa, Brave, DeepSeek, and Kepos Bridge web research with HTTP and optional browser page-rendering modes.", + "description": "DeepSeek Harness 0.1.2-rc.1 Guion-owned research surface for Exa, Brave, DeepSeek, and Kepos Bridge with HTTP and optional browser page-rendering modes.", "homepage": "https://github.com/guionai/web#readme", "bugs": { "url": "https://github.com/guionai/web/issues" @@ -71,7 +71,6 @@ "@deepseek-ai/dsh-credentials": "0.1.2-rc.1", "@deepseek-ai/dsh-settings": "0.1.2-rc.1", "@deepseek-ai/dsh-tools": "0.1.2-rc.1", - "@deepseek-ai/dsh-web": "0.1.2-rc.1", "@deepseek-ai/schemastery": "3.18.2", "react": "^18.2.0" }, @@ -90,7 +89,6 @@ "@deepseek-ai/dsh-credentials": "0.1.2-rc.1", "@deepseek-ai/dsh-settings": "0.1.2-rc.1", "@deepseek-ai/dsh-tools": "0.1.2-rc.1", - "@deepseek-ai/dsh-web": "0.1.2-rc.1", "@deepseek-ai/schemastery": "3.18.2", "@types/react": "^18.3.0", "react": "18.3.1", diff --git a/packages/dsh-web/src/client.ts b/packages/dsh-web/src/client.ts index 4344c35..7d00231 100644 --- a/packages/dsh-web/src/client.ts +++ b/packages/dsh-web/src/client.ts @@ -65,7 +65,7 @@ function installStyles(css: string): () => void { return () => style.remove(); } -type ResearchToolName = "web_fetch" | "web_links" | "web_docs"; +type ResearchToolName = "web_search" | "web_fetch" | "web_links" | "web_docs"; function WebResearchToolCard({ toolName, @@ -77,13 +77,15 @@ function WebResearchToolCard({ const running = !isToolResult(block); const error = isToolResult(block) && block.isError; const title = - name === "web_fetch" - ? "Fetch page" - : name === "web_links" - ? "Find links" - : args.action === "resolve" - ? "Find documentation" - : "Fetch documentation"; + name === "web_search" + ? "Search the web" + : name === "web_fetch" + ? "Fetch page" + : name === "web_links" + ? "Find links" + : args.action === "resolve" + ? "Find documentation" + : "Fetch documentation"; const summary = toolSummary(name, args); const details = name === "web_fetch" ? fetchDetails(args) : []; @@ -152,6 +154,23 @@ function toolBody( args: Record, output: string, ): ReturnType | null { + if (name === "web_search") { + const count = /^Found (\d+) search results/.exec(output)?.[1]; + return createElement( + "div", + { className: "guionai-web__tool-body" }, + createElement( + "p", + { className: "guionai-web__result-count" }, + count ? `${count} results found` : "Search complete", + ), + createElement( + "p", + { className: "guionai-web__excerpt" }, + excerpt(output), + ), + ); + } if (name === "web_links") { const links = linksFromOutput(output); const count = /^Found (\d+) links?/.exec(output)?.[1]; @@ -272,6 +291,13 @@ function toolSummary( name: ResearchToolName, args: Record, ): string { + if (name === "web_search") { + const queries = args.queries; + return Array.isArray(queries) && + queries.every((query) => typeof query === "string") + ? queries.join(", ") + : "Web search"; + } if (name === "web_docs") { const identifier = args.action === "resolve" ? args.query : args.library_id; return typeof identifier === "string" @@ -781,12 +807,11 @@ export function apply(ctx: ClientContext): void { ), ); ctx.slots.inject("tool.call.toolview", function* () { - for (const key of ["web_fetch", "web_links", "web_docs"]) { + for (const key of ["web_search", "web_fetch", "web_links", "web_docs"]) { yield ctx.slots.register( { name: "tool.call.toolview", key, - ...(key === "web_fetch" ? { priority: -1 } : {}), inject: () => ({}), } as never, ((props: ToolCallViewProps) => diff --git a/packages/dsh-web/src/contract.ts b/packages/dsh-web/src/contract.ts index 2eeb147..3788838 100644 --- a/packages/dsh-web/src/contract.ts +++ b/packages/dsh-web/src/contract.ts @@ -1,5 +1,4 @@ export const SETTINGS_NAMESPACE = "guionai-web" as const; -export const SEARCH_PROVIDER_ID = "guionai-web-search" as const; export { DEFAULT_KEPOS_BRIDGE_ENDPOINT, diff --git a/packages/dsh-web/src/index.ts b/packages/dsh-web/src/index.ts index e4d2c1b..a04a723 100644 --- a/packages/dsh-web/src/index.ts +++ b/packages/dsh-web/src/index.ts @@ -10,11 +10,10 @@ import { type GuionSettings, validateKeposBridgeEndpoint, } from "./contract.js"; -import { createGuionSearchProvider } from "./provider.js"; import { registerKeposTools, registerWebTools } from "./tools.js"; export const name = "guionai-dsh-web"; -export const inject = ["web", "credentials", "settings", "tools"] as const; +export const inject = ["credentials", "settings", "tools"] as const; const keposBridgeEndpointSchema = z .string() @@ -38,18 +37,10 @@ export function apply(ctx: Context): void { }, }); const operations = createWebOperations(); - ctx.web.registerSearchProvider( - createGuionSearchProvider({ - getProvider: () => (settings.get() as GuionSettings).provider, - getKeposBridgeEndpoint: () => - (settings.get() as GuionSettings).keposBridgeEndpoint, - credentials: ctx.credentials, - operations, - }), - ); const toolDependencies = { credentials: ctx.credentials, operations, + getProvider: () => (settings.get() as GuionSettings).provider, getKeposBridgeEndpoint: () => (settings.get() as GuionSettings).keposBridgeEndpoint, }; @@ -85,5 +76,5 @@ export function apply(ctx: Context): void { }; }; - ctx.effect(install, "guionai-dsh-web: provider tools"); + ctx.effect(install, "guionai-dsh-web: research tools"); } diff --git a/packages/dsh-web/src/provider.ts b/packages/dsh-web/src/provider.ts deleted file mode 100644 index 4c9adae..0000000 --- a/packages/dsh-web/src/provider.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { - createWebOperations, - type SearchCredentials, - type SearchResponse, - type WebOperations, -} from "@guionai/web-core"; -import { - credentialRef, - type CredentialRef, - type ResolvedCredential, -} from "@deepseek-ai/dsh-credentials"; -import type { WebSearchProvider, WebSearchResult } from "@deepseek-ai/dsh-web"; - -import { - BRAVE_CREDENTIAL_REF, - DEEPSEEK_CREDENTIAL_REF, - EXA_CREDENTIAL_REF, - SEARCH_PROVIDER_ID, - type SearchProviderName, -} from "./contract.js"; - -export interface SearchProviderDependencies { - getProvider: () => SearchProviderName; - /** Reads the complete bridge route from the live settings scope. */ - getKeposBridgeEndpoint: () => string; - credentials: { - resolve(ref: CredentialRef): Promise; - }; - operations?: WebOperations; -} - -function credentialFor(provider: SearchProviderName): - | { - ref: CredentialRef; - field: keyof SearchCredentials; - } - | undefined { - switch (provider) { - case "exa": - return { ref: credentialRef(EXA_CREDENTIAL_REF), field: "exaApiKey" }; - case "brave": - return { ref: credentialRef(BRAVE_CREDENTIAL_REF), field: "braveApiKey" }; - case "deepseek": - return { - ref: credentialRef(DEEPSEEK_CREDENTIAL_REF), - field: "deepseekApiKey", - }; - case "kepos-bridge": - return undefined; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function validateSearchResult( - value: unknown, - provider: SearchProviderName, -): WebSearchResult { - if (!isRecord(value) || !Array.isArray(value.results)) { - throw new Error(`${provider} search returned an invalid results array`); - } - - const sources = value.results.map((raw, index) => { - if (!isRecord(raw)) { - throw new Error( - `${provider} search returned an invalid result at index ${index}: source must be an object`, - ); - } - const link = raw.link; - const title = raw.title; - const snippet = raw.snippet; - if (typeof link !== "string" || link.length === 0) { - throw new Error( - `${provider} search returned an invalid result at index ${index}: link must be a non-empty string`, - ); - } - if (typeof title !== "string") { - throw new Error( - `${provider} search returned an invalid result at index ${index}: title must be a string`, - ); - } - if (typeof snippet !== "string") { - throw new Error( - `${provider} search returned an invalid result at index ${index}: snippet must be a string`, - ); - } - return { url: link, title, snippet }; - }); - - return { sources, truncated: false }; -} - -export function createGuionSearchProvider( - dependencies: SearchProviderDependencies, -): WebSearchProvider { - const operations = dependencies.operations ?? createWebOperations(); - return { - id: SEARCH_PROVIDER_ID, - // Selection is explicit in live DSH settings. Credentials are resolved for - // each call and never escape into process-global state. - available: () => true, - async search(request, signal): Promise { - const provider = dependencies.getProvider(); - const credential = credentialFor(provider); - let resolved: ResolvedCredential | undefined; - if (credential !== undefined) { - try { - resolved = await dependencies.credentials.resolve(credential.ref); - } catch { - throw new Error(`${provider} credential resolution failed`); - } - } - - const credentials: SearchCredentials = - credential === undefined || resolved === undefined - ? {} - : { [credential.field]: resolved.value }; - let result: SearchResponse; - try { - const searchInput = { - query: request.query, - provider, - credentials, - signal, - ...(request.maxResults === undefined || provider !== "kepos-bridge" - ? {} - : { maxResults: request.maxResults }), - ...(provider === "kepos-bridge" - ? { - keposBridgeEndpoint: dependencies.getKeposBridgeEndpoint(), - } - : {}), - }; - result = await operations.search(searchInput); - } catch (error) { - if (error instanceof Error && error.message === "Operation aborted") - throw error; - throw new Error(`${provider} search failed`); - } - try { - return validateSearchResult(result, provider); - } catch (error) { - const detail = - error instanceof Error ? error.message : "invalid source"; - throw new Error( - `${provider} search result validation failed: ${detail}`, - ); - } - }, - }; -} diff --git a/packages/dsh-web/src/tools.ts b/packages/dsh-web/src/tools.ts index d22e11a..242a1b4 100644 --- a/packages/dsh-web/src/tools.ts +++ b/packages/dsh-web/src/tools.ts @@ -18,6 +18,8 @@ import { type SGraphResult, type KeposBridgeResponse, type WebOperations, + type SearchCredentials, + type SearchResponse, } from "@guionai/web-core"; import { credentialRef, @@ -27,7 +29,13 @@ import { import type { Context } from "@deepseek-ai/cordis"; import { defineTool, type ToolDefinition } from "@deepseek-ai/dsh-tools"; -import { CONTEXT7_CREDENTIAL_REF } from "./contract.js"; +import { + BRAVE_CREDENTIAL_REF, + CONTEXT7_CREDENTIAL_REF, + DEEPSEEK_CREDENTIAL_REF, + EXA_CREDENTIAL_REF, + type SearchProviderName, +} from "./contract.js"; export interface WebToolDependencies { credentials: { @@ -35,9 +43,71 @@ export interface WebToolDependencies { }; /** Reads the current validated bridge endpoint when a Kepos tool executes. */ getKeposBridgeEndpoint: () => string; + /** Reads the selected provider from the live settings scope per search. */ + getProvider: () => SearchProviderName; operations?: WebOperations; } +const searchParameters = { + queries: { + type: "array", + required: true, + description: "One to four web search queries", + items: { + type: "string", + description: "A non-empty web search query", + }, + }, +} as const; + +type SearchToolResult = SearchResponse & { + errors?: Array<{ query: string; error: string }>; +}; + +const searchOutput = { + schema: { + type: "object", + additionalProperties: false, + properties: { + provider: { type: "string", required: true }, + results: { + type: "array", + required: true, + items: { + type: "object", + additionalProperties: false, + properties: { + title: { type: "string", required: true }, + link: { type: "string", required: true }, + snippet: { type: "string", required: true }, + position: { type: "integer", required: true }, + }, + }, + }, + errors: { + type: "array", + items: { + type: "object", + additionalProperties: false, + properties: { + query: { type: "string", required: true }, + error: { type: "string", required: true }, + }, + }, + }, + }, + } as const, + render: (_args: unknown, value: SearchToolResult) => [ + { + type: "text" as const, + text: boundedToolText( + formatSearch(value), + "Use a narrower search query to reduce results.", + ), + }, + ], +}; + const fetchParameters = { url: { type: "string", @@ -452,6 +522,193 @@ function normalizeDocs(input: unknown): DocsToolInput { return normalizeDocsToolInput(input); } +function requireSearchQueries(input: unknown): string[] { + if (!isRecord(input)) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + rejectUnknownFields(input, ["queries"], "web_search"); + if (!Array.isArray(input.queries)) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + if (input.queries.length < 1 || input.queries.length > 4) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + + const queries: string[] = []; + for (const value of input.queries) { + if (typeof value !== "string" || value.trim().length === 0) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + const query = value.trim(); + if (!queries.includes(query)) queries.push(query); + } + if (queries.length < 1 || queries.length > 4) + throw new Error("queries must be an array of 1 to 4 non-empty strings"); + return queries; +} + +function searchCredential( + provider: SearchProviderName, +): { ref: CredentialRef; field: keyof SearchCredentials } | undefined { + switch (provider) { + case "exa": + return { + ref: credentialRef(EXA_CREDENTIAL_REF), + field: "exaApiKey", + }; + case "brave": + return { + ref: credentialRef(BRAVE_CREDENTIAL_REF), + field: "braveApiKey", + }; + case "deepseek": + return { + ref: credentialRef(DEEPSEEK_CREDENTIAL_REF), + field: "deepseekApiKey", + }; + case "kepos-bridge": + return undefined; + } +} + +function searchCredentials( + field: keyof SearchCredentials, + value: string, +): SearchCredentials { + switch (field) { + case "exaApiKey": + return { exaApiKey: value }; + case "braveApiKey": + return { braveApiKey: value }; + case "deepseekApiKey": + return { deepseekApiKey: value }; + } +} + +function errorMessage(reason: unknown, secret?: string): string { + const message = reason instanceof Error ? reason.message : String(reason); + return secret !== undefined && message.includes(secret) + ? "search request failed" + : message || "web search failed"; +} + +async function executeSearchQuery( + query: string, + signal: AbortSignal | undefined, + dependencies: WebToolDependencies, + operations: WebOperations, +): Promise { + if (signal?.aborted) throw new Error("Operation aborted"); + const provider = dependencies.getProvider(); + const credential = searchCredential(provider); + let resolved: ResolvedCredential | undefined; + if (credential !== undefined) { + try { + resolved = await dependencies.credentials.resolve(credential.ref); + } catch { + throw new Error(`${provider} credential resolution failed`); + } + } + if (signal?.aborted) throw new Error("Operation aborted"); + const credentials = + credential === undefined || resolved === undefined + ? {} + : searchCredentials(credential.field, resolved.value); + const input = { + query, + provider, + credentials, + signal, + ...(provider === "kepos-bridge" + ? { keposBridgeEndpoint: dependencies.getKeposBridgeEndpoint() } + : {}), + }; + try { + return await operations.search(input); + } catch (error) { + if ( + signal?.aborted || + (error instanceof Error && error.message === "Operation aborted") + ) + throw new Error("Operation aborted"); + throw new Error(errorMessage(error, resolved?.value)); + } +} + +function mergeSearchResults(responses: SearchResponse[]): SearchResponse { + const results: SearchResponse["results"] = []; + for (let resultIndex = 0; ; resultIndex += 1) { + let added = false; + for (const response of responses) { + const result = response.results[resultIndex]; + if (result === undefined) continue; + results.push({ ...result, position: results.length + 1 }); + added = true; + } + if (!added) return { provider: responses[0]?.provider ?? "", results }; + } +} + +function formatSearch(value: SearchToolResult): string { + const lines = value.results.map( + (result) => + `${result.position}. ${result.title}\n URL: ${result.link}\n ${result.snippet}`, + ); + const failures = + value.errors === undefined || value.errors.length === 0 + ? "" + : `\n\nSearch failures:\n${value.errors.map((failure) => `- ${JSON.stringify(failure.query)}: ${failure.error}`).join("\n")}`; + if (lines.length === 0) return `No search results.${failures}`; + return `Found ${lines.length} search results (provider: ${value.provider}):\n\n${lines.join("\n\n")}${failures}`; +} + +function webSearchTool( + dependencies: WebToolDependencies, + operations: WebOperations, +): ToolDefinition { + return strictDefinition( + defineTool({ + name: "web_search", + description: + "Search the web for current facts. Provide one to four queries; repeated queries are merged and model-facing text is bounded.", + parameters: searchParameters, + output: searchOutput, + isConcurrencySafe: () => true, + async execute(args, exec) { + const queries = requireSearchQueries(args); + const settled = await Promise.allSettled( + queries.map((query) => + executeSearchQuery(query, exec.signal, dependencies, operations), + ), + ); + if (exec.signal.aborted) throw new Error("Operation aborted"); + + const responses: SearchResponse[] = []; + const errors: Array<{ query: string; error: string }> = []; + for (const [index, result] of settled.entries()) { + if (result.status === "fulfilled") responses.push(result.value); + else + errors.push({ + query: queries[index]!, + error: errorMessage(result.reason), + }); + } + if (responses.length === 0) { + throw new Error( + `web search failed for all queries:\n${errors + .map( + (failure) => + `- ${JSON.stringify(failure.query)}: ${failure.error}`, + ) + .join("\n")}`, + ); + } + const merged = mergeSearchResults(responses); + return { + ...merged, + ...(errors.length > 0 ? { errors } : {}), + } satisfies SearchToolResult; + }, + }), + ); +} + async function context7Credentials( dependencies: WebToolDependencies, ): Promise { @@ -897,9 +1154,16 @@ function webTimeTool( export function createWebToolDefinitions( dependencies: WebToolDependencies, -): readonly [ToolDefinition, ToolDefinition, ToolDefinition, ToolDefinition] { +): readonly [ + ToolDefinition, + ToolDefinition, + ToolDefinition, + ToolDefinition, + ToolDefinition, +] { const operations = dependencies.operations ?? createWebOperations(); return [ + webSearchTool(dependencies, operations), webFetchTool(dependencies, operations), webLinksTool(dependencies, operations), webDocsTool(dependencies, operations), diff --git a/packages/dsh-web/test/artifact.test.ts b/packages/dsh-web/test/artifact.test.ts index df223f5..c3d660e 100644 --- a/packages/dsh-web/test/artifact.test.ts +++ b/packages/dsh-web/test/artifact.test.ts @@ -173,6 +173,7 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { expect(packed.peerDependencies["@deepseek-ai/dsh-client-runtime"]).toBe( undefined, ); + expect(packed.peerDependencies["@deepseek-ai/dsh-web"]).toBeUndefined(); for (const [name, version] of Object.entries(packed.peerDependencies)) { if ( name.startsWith("@deepseek-ai/") && @@ -212,8 +213,7 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { ).length, ).toBeGreaterThan(10); expect(host.name).toBe("guionai-dsh-web"); - expect(host.inject).toEqual(["web", "credentials", "settings", "tools"]); - let provider: any; + expect(host.inject).toEqual(["credentials", "settings", "tools"]); const tools: any[] = []; const browser = writeFakeAgentBrowser(); const originalFetch = globalThis.fetch; @@ -259,12 +259,6 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { credentials: { resolve: async () => ({ value: "fixture-key", source: "test" }), }, - web: { - registerSearchProvider: (value: unknown) => { - provider = value; - return () => undefined; - }, - }, tools: { register: (definition: unknown) => { tools.push(definition); @@ -273,13 +267,26 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { }, effect: (execute: () => () => void) => execute(), }); + const searchTool = tools.find( + (definition) => definition.name === "web_search", + ); + if (!searchTool) + throw new Error("packed DSH artifact did not register web_search"); await expect( - provider.search({ query: "packed fixture" }), - ).resolves.toEqual({ - sources: [ - { url: "https://example.test", title: "Packed", snippet: "fixture" }, + searchTool.execute( + { queries: ["packed fixture"] }, + { signal: new AbortController().signal }, + ), + ).resolves.toMatchObject({ + provider: "Exa", + results: [ + { + link: "https://example.test", + title: "Packed", + snippet: "fixture", + position: 1, + }, ], - truncated: false, }); const fetchTool = tools.find( (definition) => definition.name === "web_fetch", @@ -348,21 +355,31 @@ describe("DSH 0.1.2-rc.1 packed package contract", () => { const patch = parse( readFileSync(join(artifactRoot, "cordis.patch.yml"), "utf8"), ) as any[]; - expect(patch.find((entry) => entry.id === "web")).toEqual({ - id: "web", - name: "@deepseek-ai/dsh-web", - config: { searchProvider: "guionai-web-search" }, - }); - expect(patch.find((entry) => entry.id === "tool-web")).toEqual({ - id: "tool-web", - disabled: true, + for (const id of [ + "web", + "web-search-deepseek", + "web-fetch-http", + "tool-web", + ]) + expect(patch.find((entry) => entry.id === id)).toEqual({ + id, + disabled: true, + }); + expect(patch.find((entry) => entry.id === "agent-presets")).toEqual({ + id: "agent-presets", + name: "@deepseek-ai/dsh-agent-presets", + config: { + includeShippedRoot: false, + includeUserRoot: true, + default: "standard", + }, }); expect( patch.find((entry) => Array.isArray(entry.insert))?.insert, ).toContainEqual({ id: "guionai-dsh-web", name: "@guionai/dsh-web", - inject: ["web", "credentials", "settings", "tools"], + inject: ["credentials", "settings", "tools"], }); }, 30_000); diff --git a/packages/dsh-web/test/client.test.ts b/packages/dsh-web/test/client.test.ts index a0c287c..d19535a 100644 --- a/packages/dsh-web/test/client.test.ts +++ b/packages/dsh-web/test/client.test.ts @@ -83,9 +83,7 @@ describe("DSH settings client credential surface", () => { }); it("shadows the host fetch view and registers dedicated views for links and docs", () => { - const registrations: Array<{ key: string; priority?: number }> = [ - { key: "web_fetch", priority: 0 }, - ]; + const registrations: Array<{ key: string; priority?: number }> = []; const fixture = fakeApi(); const ctx = { effect: (_execute: () => () => void) => () => undefined, @@ -132,9 +130,9 @@ describe("DSH settings client credential surface", () => { expect( registrations.map(({ key, priority }) => ({ key, priority })), ).toEqual([ - { key: "web_fetch", priority: 0 }, { key: SETTINGS_NAMESPACE }, - { key: "web_fetch", priority: -1 }, + { key: "web_search" }, + { key: "web_fetch" }, { key: "web_links" }, { key: "web_docs" }, ]); diff --git a/packages/dsh-web/test/package.test.ts b/packages/dsh-web/test/package.test.ts index c8f2779..e6a08ee 100644 --- a/packages/dsh-web/test/package.test.ts +++ b/packages/dsh-web/test/package.test.ts @@ -1,41 +1,26 @@ -import { Context } from "@deepseek-ai/cordis"; -import { WebRuntime } from "@deepseek-ai/dsh-web"; import { describe, expect, it } from "vitest"; -import { createWebOperations, type WebOperations } from "@guionai/web-core"; import { DEFAULT_KEPOS_BRIDGE_ENDPOINT, - SEARCH_PROVIDER_ID, type SearchProviderName, } from "../src/contract.js"; -import { createGuionSearchProvider } from "../src/provider.js"; -import { apply, SettingsSchema } from "../src/index.js"; - -function withOperations(overrides: Partial): WebOperations { - return { ...createWebOperations(), ...overrides }; -} +import { apply, inject, SettingsSchema } from "../src/index.js"; describe("DSH Web package composition", () => { - it("resolves the Kepos provider and deployed route defaults", () => { + it("keeps the live provider settings contract", () => { expect(SettingsSchema()).toMatchObject({ provider: "exa", keposBridgeEndpoint: DEFAULT_KEPOS_BRIDGE_ENDPOINT, }); expect( SettingsSchema({ - provider: "exa", + provider: "kepos-bridge", keposBridgeEndpoint: "https://bridge.example.test/route", }), ).toMatchObject({ - provider: "exa", + provider: "kepos-bridge", keposBridgeEndpoint: "https://bridge.example.test/route", }); - expect( - SettingsSchema({ - provider: "deepseek", - keposBridgeEndpoint: DEFAULT_KEPOS_BRIDGE_ENDPOINT, - }), - ).toMatchObject({ provider: "deepseek" }); expect(() => SettingsSchema({ provider: "kepos-bridge", @@ -43,46 +28,8 @@ describe("DSH Web package composition", () => { }), ).toThrow(); }); - it("works at the supported rc.1 WebRuntime provider seam for concurrent PTC queries", async () => { - const calls: string[] = []; - const root = new Context(); - await root.plugin(WebRuntime, { searchProvider: SEARCH_PROVIDER_ID }); - await root.plugin({ - inject: ["web"], - apply(ctx) { - ctx.web.registerSearchProvider( - createGuionSearchProvider({ - getProvider: () => "exa", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async () => ({ value: "test-secret", source: "file" }), - }, - operations: withOperations({ - search: async (input) => { - calls.push(input.query); - return { provider: "Exa", results: [] }; - }, - }), - }), - ); - }, - }); - const controller = new AbortController(); - await expect( - Promise.all( - ["first", "second"].map((query) => - root.web.search({ query, maxResults: 8 }, controller.signal), - ), - ), - ).resolves.toEqual([ - { sources: [], truncated: false }, - { sources: [], truncated: false }, - ]); - expect(calls).toEqual(["first", "second"]); - await root.fiber.dispose(); - }); - it("owns Kepos-only tool registrations and the settings watcher across live transitions", () => { + it("registers the complete Guion suite without an official Web service", () => { let current: { provider: SearchProviderName; keposBridgeEndpoint: string; @@ -91,7 +38,7 @@ describe("DSH Web package composition", () => { keposBridgeEndpoint: "http://fixture.test/one", }; const watchers = new Set< - (next: typeof current, prev: typeof current) => void + (next: typeof current, previous: typeof current) => void >(); const registered = new Map void>(); let effectDisposer: (() => void) | undefined; @@ -100,7 +47,7 @@ describe("DSH Web package composition", () => { register: () => ({ get: () => current, watch: ( - callback: (next: typeof current, prev: typeof current) => void, + callback: (next: typeof current, previous: typeof current) => void, ) => { watchers.add(callback); return () => watchers.delete(callback); @@ -108,7 +55,6 @@ describe("DSH Web package composition", () => { }), }, credentials: { resolve: async () => undefined }, - web: { registerSearchProvider: () => () => undefined }, tools: { register: (definition: { name: string }) => { const dispose = () => { @@ -127,13 +73,16 @@ describe("DSH Web package composition", () => { }, }; + expect(inject).toEqual(["credentials", "settings", "tools"]); apply(ctx as never); expect([...registered.keys()]).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); + const transition = (next: typeof current) => { const previous = current; current = next; @@ -144,6 +93,7 @@ describe("DSH Web package composition", () => { keposBridgeEndpoint: current.keposBridgeEndpoint, }); expect([...registered.keys()]).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", @@ -153,26 +103,17 @@ describe("DSH Web package composition", () => { "web_finance", "web_time", ]); - transition({ - provider: "kepos-bridge", - keposBridgeEndpoint: "https://fixture.test/two", - }); - expect([...registered.keys()]).toHaveLength(8); transition({ provider: "exa", keposBridgeEndpoint: current.keposBridgeEndpoint, }); expect([...registered.keys()]).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); - transition({ - provider: "kepos-bridge", - keposBridgeEndpoint: current.keposBridgeEndpoint, - }); - expect([...registered.keys()]).toHaveLength(8); effectDisposer?.(); expect(registered.size).toBe(0); expect(watchers.size).toBe(0); diff --git a/packages/dsh-web/test/provider.test.ts b/packages/dsh-web/test/provider.test.ts deleted file mode 100644 index ece317b..0000000 --- a/packages/dsh-web/test/provider.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { createWebOperations, type WebOperations } from "@guionai/web-core"; - -import { - BRAVE_CREDENTIAL_REF, - DEEPSEEK_CREDENTIAL_REF, - EXA_CREDENTIAL_REF, - SEARCH_PROVIDER_ID, -} from "../src/contract.js"; -import { createGuionSearchProvider } from "../src/provider.js"; - -function withOperations(overrides: Partial): WebOperations { - return { ...createWebOperations(), ...overrides }; -} - -describe("Guion DSH search provider", () => { - it("resolves only the namespaced DeepSeek key and forwards explicit selection", async () => { - let received: unknown; - const provider = createGuionSearchProvider({ - getProvider: () => "deepseek", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async (ref) => { - expect(ref).toBe(DEEPSEEK_CREDENTIAL_REF); - return { value: "deepseek-secret", source: "file" }; - }, - }, - operations: withOperations({ - search: async (input) => { - received = input; - return { provider: "DeepSeek", results: [] }; - }, - }), - }); - - await expect(provider.search({ query: "latest" })).resolves.toEqual({ - sources: [], - truncated: false, - }); - expect(received).toEqual({ - query: "latest", - provider: "deepseek", - credentials: { deepseekApiKey: "deepseek-secret" }, - signal: undefined, - }); - }); - - it("routes each stock PTC query through the live selected provider and resolved credential", async () => { - let received: unknown; - const provider = createGuionSearchProvider({ - getProvider: () => "brave", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async (ref) => { - expect(ref).toBe(BRAVE_CREDENTIAL_REF); - return { value: "dsh-secret", source: "file" }; - }, - }, - operations: withOperations({ - search: async (input) => { - received = input; - return { - provider: "Brave", - results: [ - { - title: "One", - link: "https://example.test/one", - snippet: "First", - position: 1, - }, - ], - }; - }, - }), - }); - const controller = new AbortController(); - const results = await Promise.all( - ["--flag-like query"].map((query) => - provider.search({ query, maxResults: 8 }, controller.signal), - ), - ); - - expect(provider.id).toBe(SEARCH_PROVIDER_ID); - expect(provider.available()).toBe(true); - expect(received).toEqual({ - query: "--flag-like query", - provider: "brave", - credentials: { braveApiKey: "dsh-secret" }, - signal: controller.signal, - }); - expect(results).toEqual([ - { - sources: [ - { url: "https://example.test/one", title: "One", snippet: "First" }, - ], - truncated: false, - }, - ]); - }); - - it("uses one resolved credential per direct core call without process environment fallback or secret leaks", async () => { - const secret = "exa-secret-never-in-error"; - const provider = createGuionSearchProvider({ - getProvider: () => "exa", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async (ref) => { - expect(ref).toBe(EXA_CREDENTIAL_REF); - return { value: secret, source: "file" }; - }, - }, - operations: withOperations({ - search: async () => { - throw new Error(`transport saw ${secret}`); - }, - }), - }); - - await expect(provider.search({ query: "failed" })).rejects.toThrow( - "exa search failed", - ); - await expect(provider.search({ query: "failed" })).rejects.not.toThrow( - secret, - ); - }); - - it("rejects malformed core output before returning a DSH result", async () => { - const provider = createGuionSearchProvider({ - getProvider: () => "exa", - getKeposBridgeEndpoint: () => "http://fixture.test/route", - credentials: { - resolve: async () => ({ value: "secret", source: "file" }), - }, - operations: withOperations({ - search: async () => ({ - provider: "Exa", - results: [{ title: "ok", link: "", snippet: "bad", position: 1 }], - }), - }), - }); - await expect(provider.search({ query: "invalid" })).rejects.toThrow( - /link must be a non-empty string/, - ); - }); - - it("routes Kepos search without resolving credentials and reads the endpoint per call", async () => { - let endpoint = "http://fixture.test/one"; - const resolve = vi.fn(async () => { - throw new Error("must not resolve a third-party credential"); - }); - const calls: unknown[] = []; - const provider = createGuionSearchProvider({ - getProvider: () => "kepos-bridge", - getKeposBridgeEndpoint: () => endpoint, - credentials: { resolve }, - operations: withOperations({ - search: async (input) => { - calls.push(input); - return { - provider: "Kepos Bridge", - results: [ - { - title: "Bridge", - link: "https://example.test", - snippet: "result", - position: 1, - }, - ], - }; - }, - }), - }); - - await provider.search({ query: "first", maxResults: 4 }); - endpoint = "https://fixture.test/two"; - await provider.search({ query: "second", maxResults: 2 }); - expect(resolve).not.toHaveBeenCalled(); - expect(calls).toEqual([ - { - query: "first", - provider: "kepos-bridge", - credentials: {}, - signal: undefined, - maxResults: 4, - keposBridgeEndpoint: "http://fixture.test/one", - }, - { - query: "second", - provider: "kepos-bridge", - credentials: {}, - signal: undefined, - maxResults: 2, - keposBridgeEndpoint: "https://fixture.test/two", - }, - ]); - }); -}); diff --git a/packages/dsh-web/test/tools.test.ts b/packages/dsh-web/test/tools.test.ts index d5e0b71..a2f4556 100644 --- a/packages/dsh-web/test/tools.test.ts +++ b/packages/dsh-web/test/tools.test.ts @@ -6,6 +6,7 @@ import { } from "@guionai/web-core"; import { + BRAVE_CREDENTIAL_REF, CONTEXT7_CREDENTIAL_REF, DEFAULT_KEPOS_BRIDGE_ENDPOINT, } from "../src/contract.js"; @@ -35,12 +36,13 @@ function dependencies( return { credentials: { resolve: async () => undefined }, getKeposBridgeEndpoint: () => DEFAULT_KEPOS_BRIDGE_ENDPOINT, + getProvider: () => "exa", ...overrides, }; } describe("DSH direct web tools", () => { - it("registers fetch, links, docs, and Sourcegraph tools with current schemas and concurrent execution", () => { + it("registers the complete research suite with current schemas and concurrency metadata", () => { const definitions = createWebToolDefinitions(dependencies()); const registered: ToolDefinition[] = []; registerWebTools( @@ -55,65 +57,322 @@ describe("DSH direct web tools", () => { dependencies(), ); expect(definitions.map((definition) => definition.name)).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); expect(registered.map((definition) => definition.name)).toEqual([ + "web_search", "web_fetch", "web_links", "web_docs", "web_source_search", ]); expect([ - definitions[0]!.isConcurrencySafe?.({ url: "https://example.test" }), - definitions[1]!.isConcurrencySafe?.({ + definitions[0]!.isConcurrencySafe?.({ queries: ["latest"] }), + definitions[1]!.isConcurrencySafe?.({ url: "https://example.test" }), + definitions[2]!.isConcurrencySafe?.({ url: "https://example.test", render: "browser", waitMs: 0, }), - definitions[2]!.isConcurrencySafe?.({ + definitions[3]!.isConcurrencySafe?.({ action: "resolve", query: "react", }), - definitions[3]!.isConcurrencySafe?.({ query: "repo:guionai" }), - ]).toEqual([true, true, true, true]); + definitions[4]!.isConcurrencySafe?.({ query: "repo:guionai" }), + ]).toEqual([true, true, true, true, true]); expect((definitions[0]!.parameters as any).additionalProperties).toBe( false, ); - expect((definitions[0]!.parameters as any).properties.render.enum).toEqual([ + expect((definitions[0]!.parameters as any).properties.queries.type).toBe( + "array", + ); + expect((definitions[1]!.parameters as any).additionalProperties).toBe( + false, + ); + expect((definitions[1]!.parameters as any).properties.render.enum).toEqual([ "http", "browser", ]); - expect((definitions[0]!.parameters as any).properties.mode.enum).toEqual([ + expect((definitions[1]!.parameters as any).properties.mode.enum).toEqual([ "auto", "full", "tree", ]); - expect((definitions[0]!.parameters as any).properties.waitMs.type).toBe( + expect((definitions[1]!.parameters as any).properties.waitMs.type).toBe( "integer", ); - expect((definitions[1]!.parameters as any).properties.limit.default).toBe( + expect((definitions[2]!.parameters as any).properties.limit.default).toBe( 100, ); - expect((definitions[1]!.parameters as any).properties.render.enum).toEqual([ + expect((definitions[2]!.parameters as any).properties.render.enum).toEqual([ "http", "browser", ]); - expect((definitions[2]!.parameters as any).properties.action.enum).toEqual([ + expect((definitions[3]!.parameters as any).properties.action.enum).toEqual([ "resolve", "fetch", ]); - expect(Object.keys((definitions[3]!.parameters as any).properties)).toEqual( + expect(Object.keys((definitions[4]!.parameters as any).properties)).toEqual( ["query", "count", "context", "timeout"], ); }); + it("trims and deduplicates queries, runs them concurrently, and keeps partial results", async () => { + const pending = new Map< + string, + { + promise: Promise<{ + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }>; + resolve: (value: { + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }) => void; + reject: (reason?: unknown) => void; + } + >(); + const deferred = () => { + let resolve!: (value: { + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise<{ + provider: "Brave"; + results: Array<{ + title: string; + link: string; + snippet: string; + position: number; + }>; + }>((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; + }; + const calls: Array<{ + query: string; + provider: unknown; + signal?: AbortSignal; + }> = []; + const search = vi.fn((input: any) => { + calls.push({ + query: input.query, + provider: input.provider, + signal: input.signal, + }); + const value = deferred(); + pending.set(input.query, value); + return value.promise; + }); + const controller = new AbortController(); + const [tool] = createWebToolDefinitions( + dependencies({ + getProvider: () => "brave", + credentials: { + resolve: async (ref) => { + expect(ref).toBe(BRAVE_CREDENTIAL_REF); + return { value: "brave-secret", source: "test" }; + }, + }, + operations: operations({ search }), + }), + ); + const resultPromise = call( + tool!, + { queries: [" first ", "second", " first", "third"] }, + controller.signal, + ); + await Promise.resolve(); + expect(calls.map(({ query }) => query)).toEqual([ + "first", + "second", + "third", + ]); + expect( + calls.every( + ({ provider, signal }) => + provider === "brave" && signal === controller.signal, + ), + ).toBe(true); + pending.get("second")!.resolve({ + provider: "Brave", + results: [ + { title: "B1", link: "https://b/1", snippet: "b", position: 1 }, + ], + }); + pending.get("third")!.reject(new Error("fixture unavailable")); + pending.get("first")!.resolve({ + provider: "Brave", + results: [ + { title: "A1", link: "https://a/1", snippet: "a", position: 1 }, + { title: "A2", link: "https://a/2", snippet: "a", position: 2 }, + ], + }); + await expect(resultPromise).resolves.toMatchObject({ + provider: "Brave", + results: [ + { title: "A1", position: 1 }, + { title: "B1", position: 2 }, + { title: "A2", position: 3 }, + ], + errors: [{ query: "third", error: "fixture unavailable" }], + }); + }); + + it("rejects invalid search arguments and accepts trimmed non-empty queries", async () => { + const search = vi.fn(async () => ({ + provider: "Exa" as const, + results: [], + })); + const [tool] = createWebToolDefinitions( + dependencies({ operations: operations({ search }) }), + ); + + await expect( + call(tool!, { queries: [" \t trimmed query \n"] }), + ).resolves.toEqual({ provider: "Exa", results: [] }); + expect(search).toHaveBeenCalledWith( + expect.objectContaining({ query: "trimmed query" }), + ); + search.mockClear(); + + for (const queries of [ + [], + ["one", "two", "three", "four", "five"], + [" "], + ["one", "\t"], + ]) { + await expect(call(tool!, { queries })).rejects.toThrow( + "queries must be an array of 1 to 4 non-empty strings", + ); + } + await expect( + call(tool!, { queries: ["one"], unexpected: true }), + ).rejects.toThrow(/does not accept field unexpected/); + expect(search).not.toHaveBeenCalled(); + }); + + it("forwards search cancellation and reports the final aborted outcome", async () => { + const controller = new AbortController(); + let resolveStarted!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + let receivedSignal: AbortSignal | undefined; + const search = vi.fn( + (input: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + receivedSignal = input.signal; + resolveStarted(); + input.signal?.addEventListener( + "abort", + () => reject(new Error("Operation aborted")), + { once: true }, + ); + }), + ); + const [tool] = createWebToolDefinitions( + dependencies({ operations: operations({ search }) }), + ); + + const pending = call(tool!, { queries: ["cancel me"] }, controller.signal); + await started; + expect(receivedSignal).toBe(controller.signal); + controller.abort(); + await expect(pending).rejects.toThrow("Operation aborted"); + }); + + it("bounds model-facing search rendering", () => { + const [tool] = createWebToolDefinitions(dependencies()); + const marker = "search-render-tail-marker"; + const blocks = tool!.output.render( + { queries: ["large"] }, + { + provider: "Exa", + results: [ + { + title: "Large result", + link: "https://example.test/large", + snippet: `${"x".repeat(60_000)}${marker}`, + position: 1, + }, + ], + }, + ); + const text = blocks[0]?.type === "text" ? blocks[0].text : ""; + + expect(text).toContain("[Truncated:"); + expect(text).not.toContain(marker); + expect(Buffer.byteLength(text, "utf8")).toBeLessThan(50 * 1024); + }); + + it("reports every failure when no search query succeeds and reads live provider credentials", async () => { + let provider: "exa" | "brave" = "exa"; + const refs: string[] = []; + const search = vi.fn(async () => { + throw new Error("provider unavailable"); + }); + const [tool] = createWebToolDefinitions( + dependencies({ + getProvider: () => provider, + credentials: { + resolve: async (ref) => { + refs.push(ref); + return { value: `${provider}-secret`, source: "test" }; + }, + }, + operations: operations({ search }), + }), + ); + await expect(call(tool!, { queries: ["one", "two"] })).rejects.toThrow( + 'web search failed for all queries:\n- "one": provider unavailable\n- "two": provider unavailable', + ); + provider = "brave"; + await expect(call(tool!, { queries: ["three"] })).rejects.toThrow( + "provider unavailable", + ); + expect(refs).toEqual([ + "GUIONAI_DSH_WEB_EXA_API_KEY", + "GUIONAI_DSH_WEB_EXA_API_KEY", + BRAVE_CREDENTIAL_REF, + ]); + expect( + search.mock.calls.map( + (call) => ((call as unknown as unknown[])[0] as any).credentials, + ), + ).toEqual([ + { exaApiKey: "exa-secret" }, + { exaApiKey: "exa-secret" }, + { braveApiKey: "brave-secret" }, + ]); + }); + it("calls the bundled operations once with current direct inputs and caller cancellation", async () => { const calls: unknown[] = []; const controller = new AbortController(); - const [fetch, links, docs, sgraph] = createWebToolDefinitions( + const [, fetch, links, docs, sgraph] = createWebToolDefinitions( dependencies({ operations: operations({ fetch: async (input, abortSignal) => { @@ -263,7 +522,7 @@ describe("DSH direct web tools", () => { }); it("preserves shared fetch validation, cancellation, and structured renderer failures", async () => { - const fetch = createWebToolDefinitions(dependencies())[0]!; + const fetch = createWebToolDefinitions(dependencies())[1]!; await expect( call(fetch, { url: "https://example.test", @@ -330,7 +589,7 @@ describe("DSH direct web tools", () => { }), }), }), - )[0]!; + )[1]!; const pending = call( cancel, { url: "https://example.test", render: "http" }, @@ -348,7 +607,7 @@ describe("DSH direct web tools", () => { }, }), }), - )[0]!; + )[1]!; await expect( call(retryDefinition, { url: "https://example.test", @@ -370,7 +629,7 @@ describe("DSH direct web tools", () => { }, }), }), - )[0]!; + )[1]!; await expect( call(allowlistDefinition, { url: "https://example.test", @@ -404,7 +663,7 @@ describe("DSH direct web tools", () => { }, }), }), - )[2]!; + )[3]!; await expect( call(docs, { action: "fetch", library_id: "/react" }), ).rejects.toThrow("web docs fetch failed"); @@ -417,7 +676,7 @@ describe("DSH direct web tools", () => { const fetch = vi.fn(); const links = vi.fn(); const docsResolve = vi.fn(); - const [fetchTool, linksTool, docsTool, sgraphTool] = + const [, fetchTool, linksTool, docsTool, sgraphTool] = createWebToolDefinitions( dependencies({ operations: operations({ fetch, links, docsResolve }) }), ); @@ -631,7 +890,7 @@ describe("DSH direct web tools", () => { content: "fixture", truncated: false, })); - const [fetchTool] = createWebToolDefinitions( + const [, fetchTool] = createWebToolDefinitions( dependencies({ operations: operations({ fetch }) }), ); await expect(call(fetchTool!, { url: " " })).resolves.toMatchObject({ diff --git a/packages/dsh-web/tsup.config.ts b/packages/dsh-web/tsup.config.ts index 2c48633..800e26e 100644 --- a/packages/dsh-web/tsup.config.ts +++ b/packages/dsh-web/tsup.config.ts @@ -16,7 +16,6 @@ const dshExternals = [ "@deepseek-ai/dsh-credentials", "@deepseek-ai/dsh-settings", "@deepseek-ai/dsh-tools", - "@deepseek-ai/dsh-web", "@deepseek-ai/schemastery", ]; diff --git a/packages/web/src/dsh.ts b/packages/web/src/dsh.ts new file mode 100644 index 0000000..a17e927 --- /dev/null +++ b/packages/web/src/dsh.ts @@ -0,0 +1,862 @@ +import { + cp, + lstat, + mkdtemp, + mkdir, + readdir, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { createRequire as createNodeRequire } from "node:module"; +import { pathToFileURL } from "node:url"; +import { parseDocument, type Document } from "yaml"; + +export const SUPPORTED_DSH_VERSION = "0.1.2-rc.1" as const; +export const OFFICIAL_PRESET_PACKAGE = + "@deepseek-ai/dsh-agent-presets" as const; +export const COMPATIBLE_PRESET_IDS = [ + "standard", + "ptc", + "cordis", + "minimal", +] as const; +export type CompatiblePresetId = (typeof COMPATIBLE_PRESET_IDS)[number]; + +const USER_PRESET_DIRECTORY = ".agent-presets" as const; + +/** + * Explicit filesystem seams used by tests and by callers embedding the CLI. + * When omitted, sync and doctor discover the installed `dsh` executable and + * use the same DSH_HOME precedence as the official harness. + */ +export interface DshPathOverrides { + dshExecutable?: string; + dshHome?: string; + sourcePackageRoot?: string; + sourcePresetRoot?: string; + userPresetRoot?: string; + environment?: NodeJS.ProcessEnv; +} + +export interface DshSyncOptions { + readonly yes?: boolean; + readonly confirmOverwrite?: ( + ids: readonly CompatiblePresetId[], + ) => boolean | Promise; +} + +export interface DshRuntimeInfo { + readonly dshExecutable?: string; + readonly dshHome: string; + readonly userPresetRoot: string; + readonly sourcePackageRoot: string; + readonly sourcePresetRoot: string; + readonly sourcePackageName: typeof OFFICIAL_PRESET_PACKAGE; + readonly sourcePackageVersion: typeof SUPPORTED_DSH_VERSION; +} + +export interface DshSyncResult { + readonly runtime: DshRuntimeInfo; + readonly ids: readonly CompatiblePresetId[]; + readonly replaced: readonly CompatiblePresetId[]; + readonly created: readonly CompatiblePresetId[]; +} + +export interface DshDoctorPreset { + readonly id: CompatiblePresetId; + readonly status: "ok" | "missing" | "conflict" | "stale"; + readonly detail?: string; +} + +export interface DshDoctorReport { + readonly ok: boolean; + readonly runtime?: DshRuntimeInfo; + readonly userPresetRoot?: string; + readonly presets: readonly DshDoctorPreset[]; + readonly issues: readonly string[]; +} + +export class DshPresetError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "DshPresetError"; + this.code = code; + } +} + +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function expandHomePath(value: string): string { + if (value === "~") return homedir(); + if (value.startsWith("~/") || value.startsWith("~\\")) + return join(homedir(), value.slice(2)); + return value; +} + +function resolveHome(options: DshPathOverrides): string { + const environment = options.environment ?? process.env; + const configured = options.dshHome; + const fromEnvironment = environment.DSH_HOME; + return resolve( + expandHomePath( + configured ?? + (typeof fromEnvironment === "string" && fromEnvironment.trim() !== "" + ? fromEnvironment + : join(homedir(), ".dsh")), + ), + ); +} + +function findDshExecutables( + environment: NodeJS.ProcessEnv = process.env, +): string[] { + try { + const output = execFileSync("which", ["-a", "dsh"], { + encoding: "utf8", + env: { ...process.env, ...environment }, + stdio: ["ignore", "pipe", "ignore"], + }); + return [ + ...new Set( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean), + ), + ]; + } catch { + return []; + } +} + +async function canonicalPath(path: string): Promise { + try { + return await (await import("node:fs/promises")).realpath(path); + } catch { + return resolve(path); + } +} + +async function packageRootForExecutable(executable: string): Promise { + const resolvedExecutable = await canonicalPath(executable); + // npm and pnpm put generated wrappers in node_modules/.bin beside the + // package they launch. This avoids depending on package-manager shim text. + const adjacentPackage = resolve( + dirname(resolvedExecutable), + "..", + "@deepseek-ai", + "dsh", + ); + if ((await readPackageManifest(adjacentPackage))?.name === "@deepseek-ai/dsh") + return canonicalPath(adjacentPackage); + + // Direct JavaScript entries and symlinked bins resolve inside the package. + let current = dirname(resolvedExecutable); + while (true) { + try { + const manifest = JSON.parse( + await readFile(join(current, "package.json"), "utf8"), + ) as UnknownRecord; + if (manifest.name === "@deepseek-ai/dsh") return current; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + throw new DshPresetError( + "dsh-runtime-unresolved", + `could not resolve the installed DSH runtime package from ${executable}`, + ); +} + +async function officialPresetPackageRoot(runtimeRoot: string): Promise { + const candidates = new Set(); + const runtimeEntry = join(runtimeRoot, "lib", "bin.js"); + try { + const require = createNodeRequire(pathToFileURL(runtimeEntry)); + candidates.add( + dirname(require.resolve(`${OFFICIAL_PRESET_PACKAGE}/package.json`)), + ); + } catch { + // The runtime may be installed with a strict package manager layout. The + // ancestor walk below handles the package-level node_modules roots. + } + // DSH's web application owns the agent-presets dependency in strict + // package-manager layouts (including pnpm's virtual store). Resolve from + // that sibling package as well as from the CLI entry itself. + const runtimeScope = dirname(runtimeRoot); + candidates.add(join(runtimeScope, "dsh-agent-presets")); + for (const sibling of ["dsh-web-app", "dsh-app-boot"]) { + try { + const require = createNodeRequire( + pathToFileURL(join(runtimeScope, sibling, "lib", "index.js")), + ); + candidates.add( + dirname(require.resolve(`${OFFICIAL_PRESET_PACKAGE}/package.json`)), + ); + } catch { + // Try the next package layout. + } + } + let current = runtimeRoot; + while (true) { + candidates.add( + join(current, "node_modules", "@deepseek-ai", "dsh-agent-presets"), + ); + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + const existing: string[] = []; + for (const candidate of candidates) { + const manifest = await readPackageManifest(candidate); + if (manifest?.name === OFFICIAL_PRESET_PACKAGE) + existing.push(await canonicalPath(candidate)); + } + const unique = [...new Set(existing)]; + if (unique.length === 0) + throw new DshPresetError( + "dsh-source-missing", + `could not resolve ${OFFICIAL_PRESET_PACKAGE} from the installed DSH runtime at ${runtimeRoot}`, + ); + if (unique.length > 1) + throw new DshPresetError( + "dsh-source-ambiguous", + `found multiple installed ${OFFICIAL_PRESET_PACKAGE} packages (${unique.join(", ")}); provide one explicit source package path`, + ); + return unique[0]!; +} + +async function packageRootForPresetPath(presetRoot: string): Promise { + let current = resolve(presetRoot); + try { + const directory = (await stat(current)).isDirectory() + ? current + : dirname(current); + current = directory; + } catch { + current = dirname(current); + } + while (true) { + try { + const manifest = JSON.parse( + await readFile(join(current, "package.json"), "utf8"), + ) as UnknownRecord; + if (manifest.name === OFFICIAL_PRESET_PACKAGE) return current; + } catch { + // Keep walking until the filesystem root. + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return resolve(presetRoot, ".."); +} + +async function readPackageManifest( + packageRoot: string, +): Promise { + try { + const value: unknown = JSON.parse( + await readFile(join(packageRoot, "package.json"), "utf8"), + ); + return isRecord(value) ? value : undefined; + } catch { + return undefined; + } +} + +async function discoverPresetPackage(options: DshPathOverrides): Promise<{ + packageRoot: string; + presetRoot: string; + executable?: string; + manifest: UnknownRecord; +}> { + const explicitPackageRoot = options.sourcePackageRoot; + const explicitPresetRoot = options.sourcePresetRoot; + let executable = options.dshExecutable; + let packageRoot: string | undefined = explicitPackageRoot; + + if (packageRoot === undefined && explicitPresetRoot !== undefined) + packageRoot = await packageRootForPresetPath(explicitPresetRoot); + + if (packageRoot === undefined) { + const candidates = + executable === undefined + ? findDshExecutables(options.environment) + : [executable]; + if (candidates.length === 0) + throw new DshPresetError( + "dsh-runtime-missing", + "could not find the installed `dsh` executable; install DSH 0.1.2-rc.1 or provide a test-owned runtime path", + ); + const canonicalCandidates = [ + ...new Set(await Promise.all(candidates.map(canonicalPath))), + ]; + if (canonicalCandidates.length !== 1) + throw new DshPresetError( + "dsh-runtime-ambiguous", + `found multiple installed dsh executables (${canonicalCandidates.join(", ")}); provide one explicit runtime path`, + ); + executable = candidates[0]; + const runtimeRoot = await packageRootForExecutable(canonicalCandidates[0]!); + packageRoot = await officialPresetPackageRoot(runtimeRoot); + } + + packageRoot = resolve(packageRoot); + const manifest = await readPackageManifest(packageRoot); + if (manifest === undefined) + throw new DshPresetError( + "dsh-source-invalid", + `source package at ${packageRoot} is missing a readable package.json manifest`, + ); + const packageName = manifest.name; + if (packageName !== OFFICIAL_PRESET_PACKAGE) + throw new DshPresetError( + "dsh-source-invalid", + `source package at ${packageRoot} is ${String(packageName ?? "unnamed")}, expected ${OFFICIAL_PRESET_PACKAGE}`, + ); + const presetRoot = resolve( + explicitPresetRoot ?? join(packageRoot, "presets"), + ); + return { packageRoot, presetRoot, executable, manifest }; +} + +async function listFiles(root: string, prefix = ""): Promise { + const entries = await readdir(join(root, prefix), { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const next = join(prefix, entry.name); + if (entry.isSymbolicLink()) + throw new DshPresetError( + "dsh-source-invalid", + `preset tree contains a symbolic link: ${next}`, + ); + if (entry.isDirectory()) files.push(...(await listFiles(root, next))); + else if (entry.isFile()) files.push(next); + else + throw new DshPresetError( + "dsh-source-invalid", + `preset tree contains a special file: ${next}`, + ); + } + return files; +} + +function parseYaml( + source: string, + filename: string, +): { + document: Document; + value: unknown; +} { + const document = parseDocument(source, { + keepSourceTokens: true, + logLevel: "silent", + }); + if (document.errors.length > 0) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} is not valid YAML: ${document.errors[0]!.message}`, + ); + try { + return { document, value: document.toJS() }; + } catch (error) { + throw new DshPresetError( + "dsh-source-invalid", + `${filename} could not be decoded: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +function rowId(row: unknown): string | undefined { + return isRecord(row) && typeof row.id === "string" ? row.id : undefined; +} + +function validateComposition( + source: string, + filename: string, + expectedWebRow: boolean, +): { document: Document; rows: unknown[]; webNode?: unknown } { + const parsed = parseYaml(source, filename); + const contents = parsed.document.contents as { items?: unknown[] } | null; + if (!Array.isArray(parsed.value) || !Array.isArray(contents?.items)) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} must contain a top-level list of composition rows`, + ); + const rows = parsed.value; + const items = contents.items; + for (const [index, row] of rows.entries()) { + if ( + !isRecord(row) || + typeof row.id !== "string" || + typeof row.name !== "string" + ) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} row ${index + 1} must contain string id and name fields`, + ); + } + const webIndexes = rows + .map((row, index) => (rowId(row) === "tool-web" ? index : -1)) + .filter((index) => index >= 0); + if (expectedWebRow) { + if (webIndexes.length !== 1) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} must contain exactly one top-level tool-web row`, + ); + const row = rows[webIndexes[0]!]!; + const rowKeys = isRecord(row) ? Object.keys(row) : []; + if ( + !isRecord(row) || + rowKeys.length !== 3 || + !rowKeys.every((key) => ["id", "name", "config"].includes(key)) || + row.name !== "@deepseek-ai/dsh-tool-web" || + row.disabled === true || + !isRecord(row.config) || + Object.keys(row.config).length !== 2 || + row.config.fetch !== true || + row.config.searchTimeoutMs !== 60_000 + ) + throw new DshPresetError( + "dsh-source-invalid", + `${filename} tool-web row does not match the supported DSH 0.1.2-rc.1 shape`, + ); + } else if (webIndexes.length !== 0) { + throw new DshPresetError( + "dsh-source-invalid", + `${filename} minimal preset must omit the top-level tool-web row`, + ); + } + return { + document: parsed.document, + rows, + webNode: webIndexes.length === 1 ? items[webIndexes[0]!] : undefined, + }; +} + +async function validateSourceTree( + sourceRoot: string, + id: CompatiblePresetId, + removeWeb: boolean, +): Promise<{ + composition: string; + metadata: string; + transformedComposition: string; +}> { + const root = join(sourceRoot, id); + let rootStat; + try { + rootStat = await lstat(root); + } catch { + throw new DshPresetError( + "dsh-source-invalid", + `official preset tree is missing: ${root}`, + ); + } + if (!rootStat.isDirectory()) + throw new DshPresetError( + "dsh-source-invalid", + `official preset path is not a directory: ${root}`, + ); + const compositionPath = join(root, "agent.cordis.yml"); + const metadataPath = join(root, "preset.yml"); + const composition = await readFile(compositionPath, "utf8").catch(() => { + throw new DshPresetError( + "dsh-source-invalid", + `official preset is missing ${id}/agent.cordis.yml`, + ); + }); + const metadata = await readFile(metadataPath, "utf8").catch(() => { + throw new DshPresetError( + "dsh-source-invalid", + `official preset is missing ${id}/preset.yml`, + ); + }); + const parsed = validateComposition( + composition, + `${id}/agent.cordis.yml`, + removeWeb, + ); + const metadataParsed = parseYaml(metadata, `${id}/preset.yml`); + if (!isRecord(metadataParsed.value)) + throw new DshPresetError( + "dsh-source-invalid", + `${id}/preset.yml must contain a mapping`, + ); + + let transformedComposition = composition; + if (removeWeb) { + const node = parsed.webNode as + | { range?: readonly [number, number, number] } + | undefined; + if (node?.range === undefined) + throw new DshPresetError( + "dsh-source-invalid", + `${id}/agent.cordis.yml tool-web row has no source range`, + ); + let start = composition.lastIndexOf("\n", node.range[0] - 1) + 1; + let end = composition.indexOf("\n", node.range[2]); + if (end < 0) end = composition.length; + else end += 1; + transformedComposition = + composition.slice(0, start) + composition.slice(end); + } + validateComposition( + transformedComposition, + `${id}/agent.cordis.yml (compatible)`, + false, + ); + return { composition, metadata, transformedComposition }; +} + +async function resolveRuntime( + options: DshPathOverrides = {}, +): Promise { + const discovered = await discoverPresetPackage(options); + const version = discovered.manifest.version; + if (typeof version !== "string" || version.trim().length === 0) + throw new DshPresetError( + "dsh-source-invalid", + `source package at ${discovered.packageRoot} is missing a package version`, + ); + if (version !== SUPPORTED_DSH_VERSION) + throw new DshPresetError( + "dsh-source-unsupported", + `unsupported ${OFFICIAL_PRESET_PACKAGE} version ${version}; this integration supports exactly ${SUPPORTED_DSH_VERSION}`, + ); + if ( + !(await stat(discovered.presetRoot).catch(() => undefined))?.isDirectory() + ) + throw new DshPresetError( + "dsh-source-missing", + `could not resolve the official preset root: ${discovered.presetRoot}`, + ); + const dshHome = resolveHome(options); + const userPresetRoot = resolve( + options.userPresetRoot ?? join(dshHome, USER_PRESET_DIRECTORY), + ); + return { + dshExecutable: discovered.executable, + dshHome, + userPresetRoot, + sourcePackageRoot: discovered.packageRoot, + sourcePresetRoot: discovered.presetRoot, + sourcePackageName: OFFICIAL_PRESET_PACKAGE, + sourcePackageVersion: SUPPORTED_DSH_VERSION, + }; +} + +export async function resolveDshRuntime( + options: DshPathOverrides = {}, +): Promise { + return resolveRuntime(options); +} + +async function assertUserRootSafe(root: string): Promise { + try { + const info = await lstat(root); + if (!info.isDirectory() || info.isSymbolicLink()) + throw new DshPresetError( + "dsh-home-invalid", + `DSH user preset root is not a directory: ${root}`, + ); + } catch (error) { + if (error instanceof DshPresetError) throw error; + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +async function stagePreset(stageRoot: string, plan: PresetPlan): Promise { + const stagedDirectory = join(stageRoot, plan.id); + await cp(plan.sourceDirectory, stagedDirectory, { + recursive: true, + force: false, + errorOnExist: true, + verbatimSymlinks: true, + }); + await writeFile( + join(stagedDirectory, "agent.cordis.yml"), + plan.compatible.get("agent.cordis.yml")!, + ); +} + +type PresetSnapshot = ReadonlyMap; +type PresetPlan = { + readonly id: CompatiblePresetId; + readonly sourceDirectory: string; + readonly official: PresetSnapshot; + readonly compatible: PresetSnapshot; +}; +type TargetState = { + readonly kind: "missing" | "official" | "compatible" | "conflict"; + readonly detail?: string; +}; + +async function buildPresetPlan( + runtime: DshRuntimeInfo, + id: CompatiblePresetId, +): Promise { + const validation = await validateSourceTree( + runtime.sourcePresetRoot, + id, + id !== "minimal", + ); + const sourceDirectory = join(runtime.sourcePresetRoot, id); + const official = new Map(); + for (const file of await listFiles(sourceDirectory)) { + official.set(file, await readFile(join(sourceDirectory, file))); + } + const compatible = new Map(official); + compatible.set( + "agent.cordis.yml", + Buffer.from(validation.transformedComposition), + ); + return { id, sourceDirectory, official, compatible }; +} + +async function compareSnapshot( + expected: PresetSnapshot, + directory: string, +): Promise { + const expectedFiles = new Set(expected.keys()); + const actualFiles = new Set(await listFiles(directory)); + for (const file of expectedFiles) + if (!actualFiles.has(file)) return `file is missing: ${file}`; + for (const file of actualFiles) + if (!expectedFiles.has(file)) return `unexpected file: ${file}`; + for (const [file, expectedBytes] of expected) { + const actualBytes = await readFile(join(directory, file)); + if ( + actualBytes.length !== expectedBytes.length || + !actualBytes.equals(expectedBytes) + ) + return `file differs: ${file}`; + } + return undefined; +} + +async function inspectTarget( + plan: PresetPlan, + directory: string, +): Promise { + let info; + try { + info = await lstat(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") + return { kind: "missing" }; + throw error; + } + if (!info.isDirectory() || info.isSymbolicLink()) + return { kind: "conflict", detail: "same-id path is not a directory" }; + try { + const compatibleMismatch = await compareSnapshot( + plan.compatible, + directory, + ); + if (compatibleMismatch === undefined) return { kind: "compatible" }; + if ((await compareSnapshot(plan.official, directory)) === undefined) + return { kind: "official" }; + return { kind: "conflict", detail: compatibleMismatch }; + } catch (error) { + return { + kind: "conflict", + detail: error instanceof Error ? error.message : "preset cannot be read", + }; + } +} + +export async function syncCompatiblePresets( + paths: DshPathOverrides = {}, + options: DshSyncOptions = {}, +): Promise { + const runtime = await resolveRuntime(paths); + const plans = await Promise.all( + COMPATIBLE_PRESET_IDS.map((id) => buildPresetPlan(runtime, id)), + ); + await assertUserRootSafe(runtime.userPresetRoot); + const states = new Map(); + const conflicts: CompatiblePresetId[] = []; + for (const plan of plans) { + const state = await inspectTarget( + plan, + join(runtime.userPresetRoot, plan.id), + ); + states.set(plan.id, state); + if (state.kind === "conflict") conflicts.push(plan.id); + } + const confirmed = + conflicts.length === 0 || + options.yes === true || + (options.confirmOverwrite !== undefined && + (await options.confirmOverwrite(conflicts))); + if (!confirmed) + throw new DshPresetError( + "dsh-confirmation-required", + `refusing to overwrite modified same-id preset${conflicts.length === 1 ? "" : "s"} ${conflicts.join(", ")}; rerun interactively or pass --yes`, + ); + + await mkdir(runtime.userPresetRoot, { recursive: true }); + const stageRoot = await mkdtemp(join(runtime.dshHome, ".guion-dsh-presets-")); + const replaced: CompatiblePresetId[] = []; + const created: CompatiblePresetId[] = []; + try { + for (const plan of plans) await stagePreset(stageRoot, plan); + for (const plan of plans) { + const target = join(runtime.userPresetRoot, plan.id); + const current = await inspectTarget(plan, target); + if ( + current.kind === "conflict" && + states.get(plan.id)?.kind !== "conflict" && + options.yes !== true + ) + throw new DshPresetError( + "dsh-preset-conflict", + `same-id preset changed after confirmation: ${target}`, + ); + const staged = join(stageRoot, plan.id); + if (current.kind === "missing") { + await rename(staged, target); + created.push(plan.id); + continue; + } + const backup = join( + runtime.userPresetRoot, + `.guion-dsh-backup-${plan.id}-${process.pid}-${Date.now()}`, + ); + await rename(target, backup); + try { + await rename(staged, target); + } catch (error) { + await rename(backup, target); + throw error; + } + replaced.push(plan.id); + await rm(backup, { recursive: true, force: true }).catch(() => undefined); + } + return { runtime, ids: COMPATIBLE_PRESET_IDS, replaced, created }; + } finally { + await rm(stageRoot, { recursive: true, force: true }).catch( + () => undefined, + ); + } +} + +async function inspectCompatiblePreset( + plan: PresetPlan, + userPresetRoot: string, +): Promise { + const state = await inspectTarget(plan, join(userPresetRoot, plan.id)); + if (state.kind === "compatible") return { id: plan.id, status: "ok" }; + if (state.kind === "missing") + return { id: plan.id, status: "missing", detail: "preset is missing" }; + if (state.kind === "official") + return { + id: plan.id, + status: "stale", + detail: "official preset has not been converted; run web dsh sync", + }; + return { id: plan.id, status: "conflict", detail: state.detail }; +} + +export async function inspectCompatiblePresets( + options: DshPathOverrides = {}, +): Promise { + let runtime: DshRuntimeInfo; + try { + runtime = await resolveRuntime(options); + } catch (error) { + const home = resolveHome(options); + const message = error instanceof Error ? error.message : String(error); + return { + ok: false, + userPresetRoot: resolve( + options.userPresetRoot ?? join(home, USER_PRESET_DIRECTORY), + ), + presets: [], + issues: [message], + }; + } + const presets: DshDoctorPreset[] = []; + const issues: string[] = []; + const plans: PresetPlan[] = []; + for (const id of COMPATIBLE_PRESET_IDS) { + try { + plans.push(await buildPresetPlan(runtime, id)); + } catch (error) { + issues.push(error instanceof Error ? error.message : String(error)); + } + } + await assertUserRootSafe(runtime.userPresetRoot).catch((error) => { + issues.push(error instanceof Error ? error.message : String(error)); + }); + if (issues.length === 0) { + for (const plan of plans) { + const preset = await inspectCompatiblePreset( + plan, + runtime.userPresetRoot, + ); + presets.push(preset); + if (preset.status !== "ok") + issues.push(`${plan.id}: ${preset.detail ?? preset.status}`); + } + } + return { + ok: issues.length === 0, + runtime, + userPresetRoot: runtime.userPresetRoot, + presets, + issues, + }; +} + +export function formatDshDoctor(report: DshDoctorReport): string { + const lines = [`DSH doctor: ${report.ok ? "OK" : "FAILED"}`]; + if (report.runtime !== undefined) { + lines.push( + `Source: ${report.runtime.sourcePackageName}@${report.runtime.sourcePackageVersion}`, + ); + lines.push(`Preset root: ${report.runtime.userPresetRoot}`); + } else if (report.userPresetRoot !== undefined) + lines.push(`Preset root: ${report.userPresetRoot}`); + for (const preset of report.presets) + lines.push( + `- ${preset.id}: ${preset.status}${preset.detail ? ` — ${preset.detail}` : ""}`, + ); + for (const issue of report.issues) lines.push(`Issue: ${issue}`); + return `${lines.join("\n")}\n`; +} + +export function formatDshSync(result: DshSyncResult): string { + const action = + result.created.length === COMPATIBLE_PRESET_IDS.length + ? "created all four presets" + : [ + result.created.length > 0 + ? `created ${result.created.join(", ")}` + : "", + result.replaced.length > 0 + ? `refreshed ${result.replaced.join(", ")}` + : "", + ] + .filter(Boolean) + .join("; "); + return `DSH compatible presets ${action} from ${result.runtime.sourcePackageName}@${result.runtime.sourcePackageVersion}.\nPreset root: ${result.runtime.userPresetRoot}\n`; +} diff --git a/packages/web/src/program.ts b/packages/web/src/program.ts index 423e84c..ba4b5eb 100644 --- a/packages/web/src/program.ts +++ b/packages/web/src/program.ts @@ -1,6 +1,14 @@ import { Command } from "commander"; +import { createInterface } from "node:readline/promises"; import { createMcpCommand } from "./mcp.js"; +import { + inspectCompatiblePresets, + formatDshDoctor, + formatDshSync, + syncCompatiblePresets, + type DshPathOverrides, +} from "./dsh.js"; import { parseHttpPort, startHttpServer } from "./serve.js"; import { DEFAULT_HTTP_HOST, DEFAULT_HTTP_PORT } from "./serve.js"; import { WEB_PACKAGE_VERSION } from "./version.js"; @@ -19,6 +27,9 @@ export type ProgramDependencies = { operations: WebOperations; credentials: () => WebCredentials; writeOut?: (text: string) => void; + /** Optional explicit DSH paths used by tests and embedded callers. */ + dsh?: DshPathOverrides; + confirmDshOverwrite?: (ids: readonly string[]) => boolean | Promise; }; export function createProgram(dependencies: ProgramDependencies): Command { @@ -36,11 +47,61 @@ export function createProgram(dependencies: ProgramDependencies): Command { .addCommand(createLinksCommand(dependencies)) .addCommand(createDocsCommand(dependencies)) .addCommand(createSGraphCommand(dependencies)) + .addCommand(createDshCommand(dependencies)) .addCommand(createServeCommand(dependencies)) .addCommand(createMcpCommand(dependencies)); return program; } +function createDshCommand(dependencies: ProgramDependencies): Command { + const writeOut = + dependencies.writeOut ?? ((text: string) => process.stdout.write(text)); + const options = dependencies.dsh ?? {}; + return new Command("dsh") + .description("Synchronize and diagnose Guion-compatible DSH presets") + .addCommand( + new Command("sync") + .description("Create or refresh compatible stock-equivalent presets") + .option( + "-y, --yes", + "overwrite modified same-id presets without prompting", + ) + .action(async (commandOptions: { yes?: boolean }) => { + const result = await syncCompatiblePresets(options, { + yes: commandOptions.yes, + confirmOverwrite: async (ids) => { + if (dependencies.confirmDshOverwrite !== undefined) + return dependencies.confirmDshOverwrite(ids); + if (!process.stdin.isTTY || !process.stderr.isTTY) return false; + const prompt = createInterface({ + input: process.stdin, + output: process.stderr, + }); + try { + const answer = await prompt.question( + `Overwrite modified same-id presets ${ids.join(", ")}? [y/N] `, + ); + return /^(?:y|yes)$/i.test(answer.trim()); + } finally { + prompt.close(); + } + }, + }); + writeOut(formatDshSync(result)); + }), + ) + .addCommand( + new Command("doctor") + .description("Check compatible preset health without changing files") + .action(async () => { + const report = await inspectCompatiblePresets(options); + writeOut(formatDshDoctor(report)); + if (!report.ok) + throw new Error("DSH compatible preset doctor found problems"); + }), + ); +} + function createServeCommand(dependencies: ProgramDependencies): Command { const writeOut = dependencies.writeOut ?? ((text: string) => process.stdout.write(text)); diff --git a/packages/web/src/runner.ts b/packages/web/src/runner.ts index d720082..3051856 100644 --- a/packages/web/src/runner.ts +++ b/packages/web/src/runner.ts @@ -1,4 +1,5 @@ import { createProgram } from "./program.js"; +import type { DshPathOverrides } from "./dsh.js"; import { FetchCapabilityError, type WebCredentials, @@ -8,6 +9,9 @@ import { export type CliDependencies = { operations: WebOperations; credentials: () => WebCredentials; + /** Optional test-owned DSH runtime/home seam for `web dsh` commands. */ + dsh?: DshPathOverrides; + confirmDshOverwrite?: (ids: readonly string[]) => boolean | Promise; }; export type CliOutput = { diff --git a/packages/web/test/dsh.test.ts b/packages/web/test/dsh.test.ts new file mode 100644 index 0000000..777ae7e --- /dev/null +++ b/packages/web/test/dsh.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import { + cp, + mkdir, + mkdtemp, + readFile, + realpath, + readdir, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse } from "yaml"; + +import { createWebOperations } from "@guionai/web-core"; +import { runCli } from "../src/runner.js"; +import { + DshPresetError, + COMPATIBLE_PRESET_IDS, + formatDshDoctor, + inspectCompatiblePresets, + resolveDshRuntime, + syncCompatiblePresets, + type DshPathOverrides, +} from "../src/dsh.js"; + +type Fixture = { + root: string; + sourcePackageRoot: string; + sourcePresetRoot: string; + dshHome: string; + options: DshPathOverrides; +}; + +const sourceComposition = (id: string, includeWeb: boolean): string => + [ + `# ${id} fixture composition`, + "- id: persona", + " name: fixture:persona", + ...(includeWeb + ? [ + "", + "- id: tool-web", + " name: '@deepseek-ai/dsh-tool-web'", + " config:", + " fetch: true", + " searchTimeoutMs: 60000", + ] + : []), + "", + "- id: tail", + " name: fixture:tail", + "", + ].join("\n"); + +async function fixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "guionai-web-dsh-test-")); + const sourcePackageRoot = join( + root, + "node_modules", + "@deepseek-ai", + "dsh-agent-presets", + ); + const sourcePresetRoot = join(sourcePackageRoot, "presets"); + const dshHome = join(root, "dsh-home"); + await mkdir(sourcePresetRoot, { recursive: true }); + await writeFile( + join(sourcePackageRoot, "package.json"), + `${JSON.stringify({ name: "@deepseek-ai/dsh-agent-presets", version: "0.1.2-rc.1" })}\n`, + ); + for (const id of COMPATIBLE_PRESET_IDS) { + const directory = join(sourcePresetRoot, id); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "agent.cordis.yml"), + sourceComposition(id, id !== "minimal"), + ); + await writeFile( + join(directory, "preset.yml"), + `name: Fixture ${id}\ndescription: A test preset\norder: 1\n`, + ); + await writeFile(join(directory, "prompt.md"), `prompt for ${id}\n`); + } + return { + root, + sourcePackageRoot, + sourcePresetRoot, + dshHome, + options: { sourcePackageRoot, dshHome }, + }; +} + +async function readComposition(value: Fixture, id: string): Promise { + return readFile( + join(value.dshHome, ".agent-presets", id, "agent.cordis.yml"), + "utf8", + ); +} + +async function dispose(value: Fixture): Promise { + await rm(value.root, { recursive: true, force: true }); +} + +describe("compatible DSH presets", () => { + it("derives all four same-id presets without ownership metadata", async () => { + const value = await fixture(); + try { + const result = await syncCompatiblePresets(value.options); + expect(result.created).toEqual([...COMPATIBLE_PRESET_IDS]); + for (const id of COMPATIBLE_PRESET_IDS) { + const directory = join(value.dshHome, ".agent-presets", id); + const rows = parse(await readComposition(value, id)) as Array< + Record + >; + expect(rows.map((row) => row.id)).toEqual(["persona", "tail"]); + expect((await readdir(directory)).sort()).toEqual([ + "agent.cordis.yml", + "preset.yml", + "prompt.md", + ]); + } + } finally { + await dispose(value); + } + }); + + it("refreshes compatible copies and retains unrelated user presets", async () => { + const value = await fixture(); + try { + await syncCompatiblePresets(value.options); + const userRoot = join(value.dshHome, ".agent-presets"); + await mkdir(join(userRoot, "yuki")); + await writeFile( + join(userRoot, "yuki", "agent.cordis.yml"), + "- id: yuki\n name: fixture:yuki\n", + ); + const before = await readComposition(value, "standard"); + const result = await syncCompatiblePresets(value.options); + expect(result.replaced).toEqual([...COMPATIBLE_PRESET_IDS]); + expect(await readComposition(value, "standard")).toBe(before); + expect((await readdir(userRoot)).sort()).toEqual( + [...COMPATIBLE_PRESET_IDS, "yuki"].sort(), + ); + } finally { + await dispose(value); + } + }); + + it("converts an exact official copy without confirmation", async () => { + const value = await fixture(); + try { + const target = join(value.dshHome, ".agent-presets", "standard"); + await mkdir(join(value.dshHome, ".agent-presets"), { recursive: true }); + await cp(join(value.sourcePresetRoot, "standard"), target, { + recursive: true, + }); + const result = await syncCompatiblePresets(value.options); + expect(result.replaced).toContain("standard"); + expect(await readComposition(value, "standard")).not.toContain( + "tool-web", + ); + } finally { + await dispose(value); + } + }); + + it("requires confirmation before replacing modified same-id content", async () => { + const value = await fixture(); + try { + const target = join(value.dshHome, ".agent-presets", "standard"); + await mkdir(target, { recursive: true }); + await writeFile(join(target, "operator.txt"), "keep me\n"); + await expect(syncCompatiblePresets(value.options)).rejects.toMatchObject({ + code: "dsh-confirmation-required", + }); + expect(await readFile(join(target, "operator.txt"), "utf8")).toBe( + "keep me\n", + ); + let prompted: readonly string[] = []; + await syncCompatiblePresets(value.options, { + confirmOverwrite: (ids) => { + prompted = ids; + return true; + }, + }); + expect(prompted).toEqual(["standard"]); + expect(await readComposition(value, "standard")).not.toContain( + "tool-web", + ); + } finally { + await dispose(value); + } + }); + + it("fails closed for unsupported versions and unexpected source rows", async () => { + const value = await fixture(); + try { + await writeFile( + join(value.sourcePackageRoot, "package.json"), + `${JSON.stringify({ name: "@deepseek-ai/dsh-agent-presets", version: "0.1.1" })}\n`, + ); + await expect(syncCompatiblePresets(value.options)).rejects.toMatchObject({ + code: "dsh-source-unsupported", + }); + expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); + + await writeFile( + join(value.sourcePackageRoot, "package.json"), + `${JSON.stringify({ name: "@deepseek-ai/dsh-agent-presets", version: "0.1.2-rc.1" })}\n`, + ); + await writeFile( + join(value.sourcePresetRoot, "standard", "agent.cordis.yml"), + sourceComposition("standard", false), + ); + await expect(syncCompatiblePresets(value.options)).rejects.toMatchObject({ + code: "dsh-source-invalid", + }); + expect(await stat(value.dshHome).catch(() => undefined)).toBeUndefined(); + } finally { + await dispose(value); + } + }); + + it("doctor compares output with the official and compatible trees", async () => { + const value = await fixture(); + try { + expect( + (await inspectCompatiblePresets(value.options)).presets.map( + (preset) => preset.status, + ), + ).toEqual(["missing", "missing", "missing", "missing"]); + await syncCompatiblePresets(value.options); + await writeFile( + join(value.dshHome, ".agent-presets", "standard", "prompt.md"), + "tampered\n", + ); + await rm(join(value.dshHome, ".agent-presets", "ptc"), { + recursive: true, + }); + await cp( + join(value.sourcePresetRoot, "ptc"), + join(value.dshHome, ".agent-presets", "ptc"), + { recursive: true }, + ); + const report = await inspectCompatiblePresets(value.options); + expect(report.ok).toBe(false); + expect(report.presets.map((preset) => preset.status)).toEqual([ + "conflict", + "stale", + "ok", + "ok", + ]); + expect(formatDshDoctor(report)).toContain("DSH doctor: FAILED"); + } finally { + await dispose(value); + } + }); + + it("supports interactive confirmation and --yes through the CLI", async () => { + const value = await fixture(); + try { + await syncCompatiblePresets(value.options); + const target = join(value.dshHome, ".agent-presets", "standard"); + await writeFile(join(target, "prompt.md"), "modified\n"); + let confirmed: readonly string[] = []; + const dependencies = { + operations: createWebOperations(), + credentials: () => ({}), + dsh: value.options, + confirmDshOverwrite: (ids: readonly string[]) => { + confirmed = ids; + return true; + }, + }; + const output = { stdout: () => undefined, stderr: () => undefined }; + expect( + await runCli(["node", "web", "dsh", "sync"], dependencies, output), + ).toBe(0); + expect(confirmed).toEqual(["standard"]); + + await writeFile(join(target, "prompt.md"), "modified again\n"); + confirmed = []; + expect( + await runCli( + ["node", "web", "dsh", "sync", "--yes"], + dependencies, + output, + ), + ).toBe(0); + expect(confirmed).toEqual([]); + expect( + await runCli(["node", "web", "dsh", "doctor"], dependencies, output), + ).toBe(0); + } finally { + await dispose(value); + } + }); + + it("requires the official package manifest and exposes actionable errors", async () => { + const value = await fixture(); + try { + expect( + ( + await resolveDshRuntime({ + sourcePresetRoot: value.sourcePresetRoot, + dshHome: value.dshHome, + }) + ).sourcePackageName, + ).toBe("@deepseek-ai/dsh-agent-presets"); + await rm(join(value.sourcePackageRoot, "package.json")); + await expect( + resolveDshRuntime({ + sourcePresetRoot: value.sourcePresetRoot, + dshHome: value.dshHome, + }), + ).rejects.toMatchObject({ code: "dsh-source-invalid" }); + const error = new DshPresetError("dsh-source-invalid", "fixture failure"); + expect(error.message).toBe("fixture failure"); + } finally { + await dispose(value); + } + }); + + it("discovers DSH beside a standard node_modules bin shim", async () => { + const value = await fixture(); + try { + const dshRoot = join(value.root, "node_modules", "@deepseek-ai", "dsh"); + const executable = join(value.root, "node_modules", ".bin", "dsh"); + await mkdir(join(dshRoot, "lib"), { recursive: true }); + await mkdir(join(value.root, "node_modules", ".bin"), { + recursive: true, + }); + await writeFile( + join(dshRoot, "package.json"), + `${JSON.stringify({ name: "@deepseek-ai/dsh", version: "0.1.2-rc.1" })}\n`, + ); + await writeFile(join(dshRoot, "lib", "bin.js"), "// fixture\n"); + await writeFile(executable, "#!/bin/sh\n"); + const runtime = await resolveDshRuntime({ + dshExecutable: executable, + dshHome: value.dshHome, + }); + expect(runtime.sourcePackageRoot).toBe( + await realpath(value.sourcePackageRoot), + ); + } finally { + await dispose(value); + } + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3209e8..347a4b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,9 +80,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: 0.1.2-rc.1 version: 0.1.2-rc.1(d08203521d340062cf3410a8ee8700ca) - '@deepseek-ai/dsh-web': - specifier: 0.1.2-rc.1 - version: 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)) '@deepseek-ai/schemastery': specifier: 3.18.2 version: 3.18.2 @@ -457,12 +454,6 @@ packages: peerDependencies: '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-web@0.1.2-rc.1': - resolution: {integrity: sha512-+umPNfJ+1Gshq38jR3tjgf4j0QaJZT5PretdHi1J+RU61Ou9Sc5w9n8haxa69os7g6FjBNxvGv8fnMeFDgcLKg==} - peerDependencies: - '@deepseek-ai/cordis': ^4.0.2 - '@deepseek-ai/dsh-llm': ^0.1.2-rc.1 - '@deepseek-ai/schemastery@3.18.2': resolution: {integrity: sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==} @@ -2841,12 +2832,6 @@ snapshots: dependencies: '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-web@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-llm@0.1.2-rc.1(@deepseek-ai/cordis@4.0.2))': - dependencies: - '@deepseek-ai/cordis': 4.0.2 - '@deepseek-ai/dsh-llm': 0.1.2-rc.1(@deepseek-ai/cordis@4.0.2) - '@deepseek-ai/schemastery': 3.18.2 - '@deepseek-ai/schemastery@3.18.2': dependencies: '@deepseek-ai/cosmokit': 1.8.3