├── .dockerignore ├── .eslintrc.js ├── .github ├── visuals │ ├── chatbox.gif │ ├── demo.gif │ ├── demo2.gif │ ├── demo3.gif │ ├── demo4.gif │ └── demo6.gif └── workflows │ ├── cd.yml │ ├── npm-publish.yml │ └── test.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── config.json ├── docker-compose.yml ├── jest.config.js ├── jest.setup.js ├── jina-ai ├── .dockerignore ├── Dockerfile ├── config.json ├── package-lock.json ├── package.json ├── src │ ├── dto │ │ └── jina-embeddings-auth.ts │ ├── lib │ │ ├── async-context.ts │ │ ├── billing.ts │ │ ├── env-config.ts │ │ ├── errors.ts │ │ ├── firestore.ts │ │ ├── logger.ts │ │ └── registry.ts │ ├── patch-express.ts │ ├── rate-limit.ts │ └── server.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── agent.test.ts │ ├── docker.test.ts │ └── server.test.ts ├── agent.ts ├── app.ts ├── cli.ts ├── config.ts ├── evals │ ├── batch-evals.ts │ └── ego-questions.json ├── example.tsx ├── get-prompt.ts ├── server.ts ├── tools │ ├── __tests__ │ │ ├── error-analyzer.test.ts │ │ ├── evaluator.test.ts │ │ ├── read.test.ts │ │ └── search.test.ts │ ├── brave-search.ts │ ├── code-sandbox.ts │ ├── dedup.ts │ ├── error-analyzer.ts │ ├── evaluator.ts │ ├── grounding.ts │ ├── jina-dedup.ts │ ├── jina-search.ts │ ├── query-rewriter.ts │ ├── read.ts │ └── serper-search.ts ├── types.ts └── utils │ ├── action-tracker.ts │ ├── i18n.json │ ├── safe-generator.ts │ ├── schemas.ts │ ├── text-tools.ts │ ├── token-tracker.ts │ └── url-tools.ts ├── tsconfig.json └── workflow.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint'], 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 5 | env: { 6 | node: true, 7 | es6: true, 8 | }, 9 | parserOptions: { 10 | ecmaVersion: 2020, 11 | sourceType: 'module', 12 | }, 13 | rules: { 14 | 'no-console': ['error', { allow: ['log', 'error'] }], 15 | '@typescript-eslint/no-var-requires': 'off', 16 | '@typescript-eslint/no-explicit-any': 'off', 17 | }, 18 | ignorePatterns: ['jina-ai/**/*'], 19 | }; 20 | -------------------------------------------------------------------------------- /.github/visuals/chatbox.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/node-DeepResearch/69f345ef8ef28f725aaa778177f6be181801411e/.github/visuals/chatbox.gif -------------------------------------------------------------------------------- /.github/visuals/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/node-DeepResearch/69f345ef8ef28f725aaa778177f6be181801411e/.github/visuals/demo.gif -------------------------------------------------------------------------------- /.github/visuals/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/node-DeepResearch/69f345ef8ef28f725aaa778177f6be181801411e/.github/visuals/demo2.gif -------------------------------------------------------------------------------- /.github/visuals/demo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/node-DeepResearch/69f345ef8ef28f725aaa778177f6be181801411e/.github/visuals/demo3.gif -------------------------------------------------------------------------------- /.github/visuals/demo4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/node-DeepResearch/69f345ef8ef28f725aaa778177f6be181801411e/.github/visuals/demo4.gif -------------------------------------------------------------------------------- /.github/visuals/demo6.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattpocock/node-DeepResearch/69f345ef8ef28f725aaa778177f6be181801411e/.github/visuals/demo6.gif -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | run-name: Build push and deploy (CD) 2 | on: 3 | push: 4 | branches: 5 | - main 6 | - ci-debug 7 | tags: 8 | - '*' 9 | 10 | jobs: 11 | build-and-push-to-gcr: 12 | runs-on: ubuntu-latest 13 | concurrency: 14 | group: ${{ github.ref_type == 'branch' && github.ref }} 15 | cancel-in-progress: true 16 | permissions: 17 | contents: read 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | lfs: true 22 | - uses: 'google-github-actions/auth@v2' 23 | with: 24 | credentials_json: '${{ secrets.GCLOUD_SERVICE_ACCOUNT_SECRET_JSON }}' 25 | - name: 'Set up Cloud SDK' 26 | uses: 'google-github-actions/setup-gcloud@v2' 27 | - name: 'Docker auth' 28 | run: |- 29 | gcloud auth configure-docker us-docker.pkg.dev --quiet 30 | - name: Set controller release version 31 | run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV 32 | - name: Set up Node.js 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 22.12.0 36 | cache: npm 37 | 38 | - name: npm install 39 | run: npm ci 40 | - name: build application 41 | run: npm run build 42 | - name: Set package version 43 | run: npm version --no-git-tag-version ${{ env.RELEASE_VERSION }} 44 | if: github.ref_type == 'tag' 45 | - name: Docker meta 46 | id: meta 47 | uses: docker/metadata-action@v5 48 | with: 49 | images: | 50 | us-docker.pkg.dev/research-df067/deepresearch/node-deep-research 51 | - name: Set up QEMU 52 | uses: docker/setup-qemu-action@v3 53 | - name: Set up Docker Buildx 54 | uses: docker/setup-buildx-action@v3 55 | - name: Build and push 56 | id: container 57 | uses: docker/build-push-action@v6 58 | with: 59 | file: jina-ai/Dockerfile 60 | push: true 61 | tags: ${{ steps.meta.outputs.tags }} 62 | labels: ${{ steps.meta.outputs.labels }} 63 | - name: Deploy with Tag 64 | run: | 65 | gcloud run deploy node-deep-research --image us-docker.pkg.dev/research-df067/deepresearch/node-deep-research@${{steps.container.outputs.imageid}} --tag ${{ env.RELEASE_VERSION }} --region us-central1 --async 66 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: NPM Publish 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Use Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: '20.x' 17 | registry-url: 'https://registry.npmjs.org' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run lint 24 | env: 25 | BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} 26 | GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} 27 | JINA_API_KEY: ${{ secrets.JINA_API_KEY }} 28 | GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} 29 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 30 | run: npm run lint 31 | 32 | - name: Build TypeScript 33 | run: npm run build 34 | 35 | - name: Update version from release 36 | run: | 37 | # Get release tag without 'v' prefix 38 | VERSION=$(echo ${{ github.ref_name }} | sed 's/^v//') 39 | # Update version in package.json 40 | npm version $VERSION --no-git-tag-version --allow-same-version 41 | 42 | - name: Publish to npm 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | run: npm publish --access public 46 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | test: 9 | if: "!startsWith(github.event.head_commit.message, 'chore')" 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Use Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '20.x' 18 | cache: 'npm' 19 | 20 | - name: Install dependencies 21 | run: npm ci 22 | 23 | - name: Run lint 24 | run: npm run lint 25 | 26 | - name: Run tests 27 | env: 28 | BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} 29 | GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} 30 | JINA_API_KEY: ${{ secrets.JINA_API_KEY }} 31 | GOOGLE_API_KEY: ${{ secrets.GEMINI_API_KEY }} 32 | OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} 33 | run: npm test 34 | 35 | - name: Set up Docker 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Run Docker tests 39 | env: 40 | BRAVE_API_KEY: mock_key 41 | GEMINI_API_KEY: mock_key 42 | JINA_API_KEY: mock_key 43 | GOOGLE_API_KEY: mock_key 44 | OPENAI_API_KEY: mock_key 45 | run: npm run test:docker 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files 2 | tasks/ 3 | context.json 4 | knowledge.json 5 | prompt-*.txt 6 | queries.json 7 | questions.json 8 | eval-*.json 9 | 10 | # Logs 11 | logs 12 | .idea 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | lerna-debug.log* 18 | .pnpm-debug.log* 19 | 20 | # Diagnostic reports (https://nodejs.org/api/report.html) 21 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 22 | 23 | # Runtime data 24 | pids 25 | *.pid 26 | *.seed 27 | *.pid.lock 28 | 29 | # Directory for instrumented libs generated by jscoverage/JSCover 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | coverage 34 | *.lcov 35 | 36 | # nyc test coverage 37 | .nyc_output 38 | 39 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 40 | .grunt 41 | 42 | # Bower dependency directory (https://bower.io/) 43 | bower_components 44 | 45 | # node-waf configuration 46 | .lock-wscript 47 | 48 | # Compiled binary addons (https://nodejs.org/api/addons.html) 49 | build/Release 50 | 51 | # Dependency directories 52 | node_modules/ 53 | jspm_packages/ 54 | 55 | # Snowpack dependency directory (https://snowpack.dev/) 56 | web_modules/ 57 | 58 | # TypeScript cache 59 | *.tsbuildinfo 60 | 61 | # Optional npm cache directory 62 | .npm 63 | 64 | # Optional eslint cache 65 | .eslintcache 66 | 67 | # Optional stylelint cache 68 | .stylelintcache 69 | 70 | # Microbundle cache 71 | .rpt2_cache/ 72 | .rts2_cache_cjs/ 73 | .rts2_cache_es/ 74 | .rts2_cache_umd/ 75 | 76 | # Optional REPL history 77 | .node_repl_history 78 | 79 | # Output of 'npm pack' 80 | *.tgz 81 | 82 | # Yarn Integrity file 83 | .yarn-integrity 84 | 85 | # dotenv environment variable files 86 | .env 87 | .env.development.local 88 | .env.test.local 89 | .env.production.local 90 | .env.local 91 | 92 | # parcel-bundler cache (https://parceljs.org/) 93 | .cache 94 | .parcel-cache 95 | 96 | # Next.js build output 97 | .next 98 | out 99 | 100 | # Nuxt.js build / generate output 101 | .nuxt 102 | dist 103 | 104 | # Gatsby files 105 | .cache/ 106 | # Comment in the public line in if your project uses Gatsby and not Next.js 107 | # https://nextjs.org/blog/next-9-1#public-directory-support 108 | # public 109 | 110 | # vuepress build output 111 | .vuepress/dist 112 | 113 | # vuepress v2.x temp and cache directory 114 | .temp 115 | .cache 116 | 117 | # Docusaurus cache and generated files 118 | .docusaurus 119 | 120 | # Serverless directories 121 | .serverless/ 122 | 123 | # FuseBox cache 124 | .fusebox/ 125 | 126 | # DynamoDB Local files 127 | .dynamodb/ 128 | 129 | # TernJS port file 130 | .tern-port 131 | 132 | # Stores VSCode versions used for testing VSCode extensions 133 | .vscode-test 134 | 135 | # yarn v2 136 | .yarn/cache 137 | .yarn/unplugged 138 | .yarn/build-state.yml 139 | .yarn/install-state.gz 140 | .pnp.* 141 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach", 6 | "port": 9229, 7 | "request": "attach", 8 | "skipFiles": ["/**"], 9 | "type": "node" 10 | }, 11 | { 12 | "name": "Attach by Process ID", 13 | "processId": "${command:PickProcess}", 14 | "request": "attach", 15 | "skipFiles": ["/**"], 16 | "type": "node" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "presentation-mode.configBackup": { 3 | "editor.rulers": "undefined", 4 | "eslint.enable": "undefined", 5 | "explorer.autoReveal": "undefined", 6 | "workbench.statusBar.visible": "undefined", 7 | "workbench.editor.showTabs": "undefined", 8 | "editor.lineNumbers": "undefined", 9 | "editor.folding": "undefined", 10 | "github.copilot.enable": "undefined", 11 | "workbench.colorCustomizations": "undefined", 12 | "explorer.fileNesting.patterns": "undefined", 13 | "explorer.compactFolders": "undefined", 14 | "breadcrumbs.filePath": "undefined", 15 | "workbench.startupEditor": "undefined", 16 | "editor.lightbulb.enabled": "undefined", 17 | "editor.renderLineHighlight": "undefined", 18 | "editor.guides.highlightActiveIndentation": "undefined", 19 | "editor.guides.indentation": "undefined", 20 | "editor.guides.highlightActiveBracketPair": "undefined", 21 | "editor.matchBrackets": "undefined", 22 | "editor.occurrencesHighlight": "undefined", 23 | "editor.selectionHighlight": "undefined", 24 | "workbench.colorTheme": "undefined", 25 | "zenMode.showTabs": "undefined", 26 | "workbench.editor.labelFormat": "undefined", 27 | "window.autoDetectColorScheme": "undefined" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- BUILD STAGE ---- 2 | FROM node:20-slim AS builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm install --ignore-scripts 12 | 13 | # Copy application code 14 | COPY . . 15 | 16 | # Build the application 17 | RUN npm run build --ignore-scripts 18 | 19 | # ---- PRODUCTION STAGE ---- 20 | FROM node:20-slim AS production 21 | 22 | # Set working directory 23 | WORKDIR /app 24 | 25 | # Copy package.json and package-lock.json 26 | COPY package*.json ./ 27 | 28 | # Install production dependencies only 29 | RUN npm install --production --ignore-scripts 30 | 31 | # Copy config.json and built files from builder 32 | COPY --from=builder /app/config.json ./ 33 | COPY --from=builder /app/dist ./dist 34 | 35 | # Set environment variables (Recommended to set at runtime, avoid hardcoding) 36 | ENV GEMINI_API_KEY=${GEMINI_API_KEY} 37 | ENV OPENAI_API_KEY=${OPENAI_API_KEY} 38 | ENV JINA_API_KEY=${JINA_API_KEY} 39 | ENV BRAVE_API_KEY=${BRAVE_API_KEY} 40 | 41 | # Expose the port the app runs on 42 | EXPOSE 3000 43 | 44 | # Set startup command 45 | CMD ["node", "./dist/server.js"] 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020-2025 Jina AI Limited. All rights reserved. 2 | 3 | 4 | Apache License 5 | Version 2.0, January 2004 6 | http://www.apache.org/licenses/ 7 | 8 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 9 | 10 | 1. Definitions. 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, 13 | and distribution as defined by Sections 1 through 9 of this document. 14 | 15 | "Licensor" shall mean the copyright owner or entity authorized by 16 | the copyright owner that is granting the License. 17 | 18 | "Legal Entity" shall mean the union of the acting entity and all 19 | other entities that control, are controlled by, or are under common 20 | control with that entity. For the purposes of this definition, 21 | "control" means (i) the power, direct or indirect, to cause the 22 | direction or management of such entity, whether by contract or 23 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 24 | outstanding shares, or (iii) beneficial ownership of such entity. 25 | 26 | "You" (or "Your") shall mean an individual or Legal Entity 27 | exercising permissions granted by this License. 28 | 29 | "Source" form shall mean the preferred form for making modifications, 30 | including but not limited to software source code, documentation 31 | source, and configuration files. 32 | 33 | "Object" form shall mean any form resulting from mechanical 34 | transformation or translation of a Source form, including but 35 | not limited to compiled object code, generated documentation, 36 | and conversions to other media types. 37 | 38 | "Work" shall mean the work of authorship, whether in Source or 39 | Object form, made available under the License, as indicated by a 40 | copyright notice that is included in or attached to the work 41 | (an example is provided in the Appendix below). 42 | 43 | "Derivative Works" shall mean any work, whether in Source or Object 44 | form, that is based on (or derived from) the Work and for which the 45 | editorial revisions, annotations, elaborations, or other modifications 46 | represent, as a whole, an original work of authorship. For the purposes 47 | of this License, Derivative Works shall not include works that remain 48 | separable from, or merely link (or bind by name) to the interfaces of, 49 | the Work and Derivative Works thereof. 50 | 51 | "Contribution" shall mean any work of authorship, including 52 | the original version of the Work and any modifications or additions 53 | to that Work or Derivative Works thereof, that is intentionally 54 | submitted to Licensor for inclusion in the Work by the copyright owner 55 | or by an individual or Legal Entity authorized to submit on behalf of 56 | the copyright owner. For the purposes of this definition, "submitted" 57 | means any form of electronic, verbal, or written communication sent 58 | to the Licensor or its representatives, including but not limited to 59 | communication on electronic mailing lists, source code control systems, 60 | and issue tracking systems that are managed by, or on behalf of, the 61 | Licensor for the purpose of discussing and improving the Work, but 62 | excluding communication that is conspicuously marked or otherwise 63 | designated in writing by the copyright owner as "Not a Contribution." 64 | 65 | "Contributor" shall mean Licensor and any individual or Legal Entity 66 | on behalf of whom a Contribution has been received by Licensor and 67 | subsequently incorporated within the Work. 68 | 69 | 2. Grant of Copyright License. Subject to the terms and conditions of 70 | this License, each Contributor hereby grants to You a perpetual, 71 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 72 | copyright license to reproduce, prepare Derivative Works of, 73 | publicly display, publicly perform, sublicense, and distribute the 74 | Work and such Derivative Works in Source or Object form. 75 | 76 | 3. Grant of Patent License. Subject to the terms and conditions of 77 | this License, each Contributor hereby grants to You a perpetual, 78 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 79 | (except as stated in this section) patent license to make, have made, 80 | use, offer to sell, sell, import, and otherwise transfer the Work, 81 | where such license applies only to those patent claims licensable 82 | by such Contributor that are necessarily infringed by their 83 | Contribution(s) alone or by combination of their Contribution(s) 84 | with the Work to which such Contribution(s) was submitted. If You 85 | institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work 87 | or a Contribution incorporated within the Work constitutes direct 88 | or contributory patent infringement, then any patent licenses 89 | granted to You under this License for that Work shall terminate 90 | as of the date such litigation is filed. 91 | 92 | 4. Redistribution. You may reproduce and distribute copies of the 93 | Work or Derivative Works thereof in any medium, with or without 94 | modifications, and in Source or Object form, provided that You 95 | meet the following conditions: 96 | 97 | (a) You must give any other recipients of the Work or 98 | Derivative Works a copy of this License; and 99 | 100 | (b) You must cause any modified files to carry prominent notices 101 | stating that You changed the files; and 102 | 103 | (c) You must retain, in the Source form of any Derivative Works 104 | that You distribute, all copyright, patent, trademark, and 105 | attribution notices from the Source form of the Work, 106 | excluding those notices that do not pertain to any part of 107 | the Derivative Works; and 108 | 109 | (d) If the Work includes a "NOTICE" text file as part of its 110 | distribution, then any Derivative Works that You distribute must 111 | include a readable copy of the attribution notices contained 112 | within such NOTICE file, excluding those notices that do not 113 | pertain to any part of the Derivative Works, in at least one 114 | of the following places: within a NOTICE text file distributed 115 | as part of the Derivative Works; within the Source form or 116 | documentation, if provided along with the Derivative Works; or, 117 | within a display generated by the Derivative Works, if and 118 | wherever such third-party notices normally appear. The contents 119 | of the NOTICE file are for informational purposes only and 120 | do not modify the License. You may add Your own attribution 121 | notices within Derivative Works that You distribute, alongside 122 | or as an addendum to the NOTICE text from the Work, provided 123 | that such additional attribution notices cannot be construed 124 | as modifying the License. 125 | 126 | You may add Your own copyright statement to Your modifications and 127 | may provide additional or different license terms and conditions 128 | for use, reproduction, or distribution of Your modifications, or 129 | for any such Derivative Works as a whole, provided Your use, 130 | reproduction, and distribution of the Work otherwise complies with 131 | the conditions stated in this License. 132 | 133 | 5. Submission of Contributions. Unless You explicitly state otherwise, 134 | any Contribution intentionally submitted for inclusion in the Work 135 | by You to the Licensor shall be under the terms and conditions of 136 | this License, without any additional terms or conditions. 137 | Notwithstanding the above, nothing herein shall supersede or modify 138 | the terms of any separate license agreement you may have executed 139 | with Licensor regarding such Contributions. 140 | 141 | 6. Trademarks. This License does not grant permission to use the trade 142 | names, trademarks, service marks, or product names of the Licensor, 143 | except as required for reasonable and customary use in describing the 144 | origin of the Work and reproducing the content of the NOTICE file. 145 | 146 | 7. Disclaimer of Warranty. Unless required by applicable law or 147 | agreed to in writing, Licensor provides the Work (and each 148 | Contributor provides its Contributions) on an "AS IS" BASIS, 149 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 150 | implied, including, without limitation, any warranties or conditions 151 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 152 | PARTICULAR PURPOSE. You are solely responsible for determining the 153 | appropriateness of using or redistributing the Work and assume any 154 | risks associated with Your exercise of permissions under this License. 155 | 156 | 8. Limitation of Liability. In no event and under no legal theory, 157 | whether in tort (including negligence), contract, or otherwise, 158 | unless required by applicable law (such as deliberate and grossly 159 | negligent acts) or agreed to in writing, shall any Contributor be 160 | liable to You for damages, including any direct, indirect, special, 161 | incidental, or consequential damages of any character arising as a 162 | result of this License or out of the use or inability to use the 163 | Work (including but not limited to damages for loss of goodwill, 164 | work stoppage, computer failure or malfunction, or any and all 165 | other commercial damages or losses), even if such Contributor 166 | has been advised of the possibility of such damages. 167 | 168 | 9. Accepting Warranty or Additional Liability. While redistributing 169 | the Work or Derivative Works thereof, You may choose to offer, 170 | and charge a fee for, acceptance of support, warranty, indemnity, 171 | or other liability obligations and/or rights consistent with this 172 | License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf 174 | of any other Contributor, and only if You agree to indemnify, 175 | defend, and hold each Contributor harmless for any liability 176 | incurred by, or claims asserted against, such Contributor by reason 177 | of your accepting any such warranty or additional liability. 178 | 179 | END OF TERMS AND CONDITIONS 180 | 181 | Copyright 2020-2021 Jina AI Limited 182 | 183 | Licensed under the Apache License, Version 2.0 (the "License"); 184 | you may not use this file except in compliance with the License. 185 | You may obtain a copy of the License at 186 | 187 | http://www.apache.org/licenses/LICENSE-2.0 188 | 189 | Unless required by applicable law or agreed to in writing, software 190 | distributed under the License is distributed on an "AS IS" BASIS, 191 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 192 | See the License for the specific language governing permissions and 193 | limitations under the License. 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DeepResearch 2 | 3 | [Official UI](https://search.jina.ai/) | [UI Code](https://github.com/jina-ai/deepsearch-ui) | [Official API](https://jina.ai/deepsearch) | [Evaluation](#evaluation) 4 | 5 | Keep searching, reading webpages, reasoning until an answer is found (or the token budget is exceeded). Useful for deeply investigating a query. 6 | 7 | > [!IMPORTANT] 8 | > Unlike OpenAI/Gemini/Perplexity's "Deep Research", we focus solely on **finding the right answers via our iterative process**. We don't optimize for long-form articles, that's a **completely different problem** – so if you need quick, concise answers from deep search, you're in the right place. If you're looking for AI-generated long reports like OpenAI/Gemini/Perplexity does, this isn't for you. 9 | 10 | ```mermaid 11 | --- 12 | config: 13 | theme: mc 14 | look: handDrawn 15 | --- 16 | flowchart LR 17 | subgraph Loop["until budget exceed"] 18 | direction LR 19 | Search["Search"] 20 | Read["Read"] 21 | Reason["Reason"] 22 | end 23 | Query(["Query"]) --> Loop 24 | Search --> Read 25 | Read --> Reason 26 | Reason --> Search 27 | Loop --> Answer(["Answer"]) 28 | 29 | ``` 30 | 31 | ## Install 32 | 33 | ```bash 34 | git clone https://github.com/jina-ai/node-DeepResearch.git 35 | cd node-DeepResearch 36 | npm install 37 | ``` 38 | 39 | [安装部署视频教程 on Youtube](https://youtu.be/vrpraFiPUyA) 40 | 41 | It is also available on npm but not recommended for now, as the code is still under active development. 42 | 43 | ## Usage 44 | 45 | We use Gemini (latest `gemini-2.0-flash`) / OpenAI / [LocalLLM](#use-local-llm) for reasoning, [Jina Reader](https://jina.ai/reader) for searching and reading webpages, you can get a free API key with 1M tokens from jina.ai. 46 | 47 | ```bash 48 | export GEMINI_API_KEY=... # for gemini 49 | # export OPENAI_API_KEY=... # for openai 50 | # export LLM_PROVIDER=openai # for openai 51 | export JINA_API_KEY=jina_... # free jina api key, get from https://jina.ai/reader 52 | 53 | npm run dev $QUERY 54 | ``` 55 | 56 | ### Official Site 57 | 58 | You can try it on [our official site](https://search.jina.ai). 59 | 60 | ### Official API 61 | 62 | You can also use [our official DeepSearch API](https://jina.ai/deepsearch): 63 | 64 | ``` 65 | https://deepsearch.jina.ai/v1/chat/completions 66 | ``` 67 | 68 | You can use it with any OpenAI-compatible client. 69 | 70 | For the authentication Bearer, API key, rate limit, get from https://jina.ai/deepsearch. 71 | 72 | #### Client integration guidelines 73 | 74 | If you are building a web/local/mobile client that uses `Jina DeepSearch API`, here are some design guidelines: 75 | 76 | - Our API is fully compatible with [OpenAI API schema](https://platform.openai.com/docs/api-reference/chat/create), this should greatly simplify the integration process. The model name is `jina-deepsearch-v1`. 77 | - Our DeepSearch API is a reasoning+search grounding LLM, so it's best for questions that require deep reasoning and search. 78 | - Two special tokens are introduced `...`. Please render them with care. 79 | - Citations are often provided, and in [Github-flavored markdown footnote format](https://github.blog/changelog/2021-09-30-footnotes-now-supported-in-markdown-fields/), e.g. `[^1]`, `[^2]`, ... 80 | - Guide the user to get a Jina API key from https://jina.ai, with 1M free tokens for new API key. 81 | - There are rate limits, [between 10RPM to 30RPM depending on the API key tier](https://jina.ai/contact-sales#rate-limit). 82 | - [Download Jina AI logo here](https://jina.ai/logo-Jina-1024.zip) 83 | 84 | ## Demo 85 | 86 | > was recorded with `gemini-1.5-flash`, the latest `gemini-2.0-flash` leads to much better results! 87 | 88 | Query: `"what is the latest blog post's title from jina ai?"` 89 | 3 steps; answer is correct! 90 | ![demo1](.github/visuals/demo.gif) 91 | 92 | Query: `"what is the context length of readerlm-v2?"` 93 | 2 steps; answer is correct! 94 | ![demo1](.github/visuals/demo3.gif) 95 | 96 | Query: `"list all employees from jina ai that u can find, as many as possible"` 97 | 11 steps; partially correct! but im not in the list :( 98 | ![demo1](.github/visuals/demo2.gif) 99 | 100 | Query: `"who will be the biggest competitor of Jina AI"` 101 | 42 steps; future prediction kind, so it's arguably correct! atm Im not seeing `weaviate` as a competitor, but im open for the future "i told you so" moment. 102 | ![demo1](.github/visuals/demo4.gif) 103 | 104 | More examples: 105 | 106 | ``` 107 | # example: no tool calling 108 | npm run dev "1+1=" 109 | npm run dev "what is the capital of France?" 110 | 111 | # example: 2-step 112 | npm run dev "what is the latest news from Jina AI?" 113 | 114 | # example: 3-step 115 | npm run dev "what is the twitter account of jina ai's founder" 116 | 117 | # example: 13-step, ambiguious question (no def of "big") 118 | npm run dev "who is bigger? cohere, jina ai, voyage?" 119 | 120 | # example: open question, research-like, long chain of thoughts 121 | npm run dev "who will be president of US in 2028?" 122 | npm run dev "what should be jina ai strategy for 2025?" 123 | ``` 124 | 125 | ## Use Local LLM 126 | 127 | > Note, not every LLM works with our reasoning flow, we need those who support structured output (sometimes called JSON Schema output, object output) well. Feel free to purpose a PR to add more open-source LLMs to the working list. 128 | 129 | If you use Ollama or LMStudio, you can redirect the reasoning request to your local LLM by setting the following environment variables: 130 | 131 | ```bash 132 | export LLM_PROVIDER=openai # yes, that's right - for local llm we still use openai client 133 | export OPENAI_BASE_URL=http://127.0.0.1:1234/v1 # your local llm endpoint 134 | export OPENAI_API_KEY=whatever # random string would do, as we don't use it (unless your local LLM has authentication) 135 | export DEFAULT_MODEL_NAME=qwen2.5-7b # your local llm model name 136 | ``` 137 | 138 | ## OpenAI-Compatible Server API 139 | 140 | If you have a GUI client that supports OpenAI API (e.g. [CherryStudio](https://docs.cherry-ai.com/), [Chatbox](https://github.com/Bin-Huang/chatbox)) , you can simply config it to use this server. 141 | 142 | ![demo1](.github/visuals/demo6.gif) 143 | 144 | Start the server: 145 | 146 | ```bash 147 | # Without authentication 148 | npm run serve 149 | 150 | # With authentication (clients must provide this secret as Bearer token) 151 | npm run serve --secret=your_secret_token 152 | ``` 153 | 154 | The server will start on http://localhost:3000 with the following endpoint: 155 | 156 | ### POST /v1/chat/completions 157 | 158 | ```bash 159 | # Without authentication 160 | curl http://localhost:3000/v1/chat/completions \ 161 | -H "Content-Type: application/json" \ 162 | -d '{ 163 | "model": "jina-deepsearch-v1", 164 | "messages": [ 165 | { 166 | "role": "user", 167 | "content": "Hello!" 168 | } 169 | ] 170 | }' 171 | 172 | # With authentication (when server is started with --secret) 173 | curl http://localhost:3000/v1/chat/completions \ 174 | -H "Content-Type: application/json" \ 175 | -H "Authorization: Bearer your_secret_token" \ 176 | -d '{ 177 | "model": "jina-deepsearch-v1", 178 | "messages": [ 179 | { 180 | "role": "user", 181 | "content": "Hello!" 182 | } 183 | ], 184 | "stream": true 185 | }' 186 | ``` 187 | 188 | Response format: 189 | 190 | ```json 191 | { 192 | "id": "chatcmpl-123", 193 | "object": "chat.completion", 194 | "created": 1677652288, 195 | "model": "jina-deepsearch-v1", 196 | "system_fingerprint": "fp_44709d6fcb", 197 | "choices": [ 198 | { 199 | "index": 0, 200 | "message": { 201 | "role": "assistant", 202 | "content": "YOUR FINAL ANSWER" 203 | }, 204 | "logprobs": null, 205 | "finish_reason": "stop" 206 | } 207 | ], 208 | "usage": { 209 | "prompt_tokens": 9, 210 | "completion_tokens": 12, 211 | "total_tokens": 21 212 | } 213 | } 214 | ``` 215 | 216 | For streaming responses (stream: true), the server sends chunks in this format: 217 | 218 | ```json 219 | { 220 | "id": "chatcmpl-123", 221 | "object": "chat.completion.chunk", 222 | "created": 1694268190, 223 | "model": "jina-deepsearch-v1", 224 | "system_fingerprint": "fp_44709d6fcb", 225 | "choices": [ 226 | { 227 | "index": 0, 228 | "delta": { 229 | "content": "..." 230 | }, 231 | "logprobs": null, 232 | "finish_reason": null 233 | } 234 | ] 235 | } 236 | ``` 237 | 238 | Note: The think content in streaming responses is wrapped in XML tags: 239 | 240 | ``` 241 | 242 | [thinking steps...] 243 | 244 | [final answer] 245 | ``` 246 | 247 | ## Docker Setup 248 | 249 | ### Build Docker Image 250 | 251 | To build the Docker image for the application, run the following command: 252 | 253 | ```bash 254 | docker build -t deepresearch:latest . 255 | ``` 256 | 257 | ### Run Docker Container 258 | 259 | To run the Docker container, use the following command: 260 | 261 | ```bash 262 | docker run -p 3000:3000 --env GEMINI_API_KEY=your_gemini_api_key --env JINA_API_KEY=your_jina_api_key deepresearch:latest 263 | ``` 264 | 265 | ### Docker Compose 266 | 267 | You can also use Docker Compose to manage multi-container applications. To start the application with Docker Compose, run: 268 | 269 | ```bash 270 | docker-compose up 271 | ``` 272 | 273 | ## How Does it Work? 274 | 275 | Not sure a flowchart helps, but here it is: 276 | 277 | ```mermaid 278 | flowchart TD 279 | Start([Start]) --> Init[Initialize context & variables] 280 | Init --> CheckBudget{Token budget
exceeded?} 281 | CheckBudget -->|No| GetQuestion[Get current question
from gaps] 282 | CheckBudget -->|Yes| BeastMode[Enter Beast Mode] 283 | 284 | GetQuestion --> GenPrompt[Generate prompt] 285 | GenPrompt --> ModelGen[Generate response
using Gemini] 286 | ModelGen --> ActionCheck{Check action
type} 287 | 288 | ActionCheck -->|answer| AnswerCheck{Is original
question?} 289 | AnswerCheck -->|Yes| EvalAnswer[Evaluate answer] 290 | EvalAnswer --> IsGoodAnswer{Is answer
definitive?} 291 | IsGoodAnswer -->|Yes| HasRefs{Has
references?} 292 | HasRefs -->|Yes| End([End]) 293 | HasRefs -->|No| GetQuestion 294 | IsGoodAnswer -->|No| StoreBad[Store bad attempt
Reset context] 295 | StoreBad --> GetQuestion 296 | 297 | AnswerCheck -->|No| StoreKnowledge[Store as intermediate
knowledge] 298 | StoreKnowledge --> GetQuestion 299 | 300 | ActionCheck -->|reflect| ProcessQuestions[Process new
sub-questions] 301 | ProcessQuestions --> DedupQuestions{New unique
questions?} 302 | DedupQuestions -->|Yes| AddGaps[Add to gaps queue] 303 | DedupQuestions -->|No| DisableReflect[Disable reflect
for next step] 304 | AddGaps --> GetQuestion 305 | DisableReflect --> GetQuestion 306 | 307 | ActionCheck -->|search| SearchQuery[Execute search] 308 | SearchQuery --> NewURLs{New URLs
found?} 309 | NewURLs -->|Yes| StoreURLs[Store URLs for
future visits] 310 | NewURLs -->|No| DisableSearch[Disable search
for next step] 311 | StoreURLs --> GetQuestion 312 | DisableSearch --> GetQuestion 313 | 314 | ActionCheck -->|visit| VisitURLs[Visit URLs] 315 | VisitURLs --> NewContent{New content
found?} 316 | NewContent -->|Yes| StoreContent[Store content as
knowledge] 317 | NewContent -->|No| DisableVisit[Disable visit
for next step] 318 | StoreContent --> GetQuestion 319 | DisableVisit --> GetQuestion 320 | 321 | BeastMode --> FinalAnswer[Generate final answer] --> End 322 | ``` 323 | 324 | ## Evaluation 325 | 326 | I kept the evaluation simple, LLM-as-a-judge and collect some [ego questions](./src/evals/ego-questions.json) for evaluation. These are the questions about Jina AI that I know 100% the answer but LLMs do not. 327 | 328 | I mainly look at 3 things: total steps, total tokens, and the correctness of the final answer. 329 | 330 | ```bash 331 | npm run eval ./src/evals/questions.json 332 | ``` 333 | 334 | Here's the table comparing plain `gemini-2.0-flash` and `gemini-2.0-flash + node-deepresearch` on the ego set. 335 | 336 | Plain `gemini-2.0-flash` can be run by setting `tokenBudget` to zero, skipping the while-loop and directly answering the question. 337 | 338 | It should not be surprised that plain `gemini-2.0-flash` has a 0% pass rate, as I intentionally filtered out the questions that LLMs can answer. 339 | 340 | | Metric | gemini-2.0-flash | #188f1bb | 341 | | -------------- | ---------------- | -------- | 342 | | Pass Rate | 0% | 75% | 343 | | Average Steps | 1 | 4 | 344 | | Maximum Steps | 1 | 13 | 345 | | Minimum Steps | 1 | 2 | 346 | | Median Steps | 1 | 3 | 347 | | Average Tokens | 428 | 68,574 | 348 | | Median Tokens | 434 | 31,541 | 349 | | Maximum Tokens | 463 | 363,655 | 350 | | Minimum Tokens | 374 | 7,963 | 351 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "https_proxy": "", 4 | "OPENAI_BASE_URL": "", 5 | "GEMINI_API_KEY": "", 6 | "OPENAI_API_KEY": "", 7 | "JINA_API_KEY": "", 8 | "BRAVE_API_KEY": "", 9 | "SERPER_API_KEY": "", 10 | "DEFAULT_MODEL_NAME": "" 11 | }, 12 | "defaults": { 13 | "search_provider": "jina", 14 | "llm_provider": "gemini", 15 | "step_sleep": 5000 16 | }, 17 | "providers": { 18 | "gemini": { 19 | "createClient": "createGoogleGenerativeAI" 20 | }, 21 | "openai": { 22 | "createClient": "createOpenAI", 23 | "clientConfig": { 24 | "compatibility": "strict" 25 | } 26 | } 27 | }, 28 | "models": { 29 | "gemini": { 30 | "default": { 31 | "model": "gemini-2.0-flash", 32 | "temperature": 0, 33 | "maxTokens": 2000 34 | }, 35 | "tools": { 36 | "coder": { "temperature": 0.7 }, 37 | "searchGrounding": { "temperature": 0 }, 38 | "dedup": { "temperature": 0.1 }, 39 | "evaluator": { "temperature": 0.6, "maxTokens": 200 }, 40 | "errorAnalyzer": {}, 41 | "queryRewriter": { "temperature": 0.1 }, 42 | "agent": { "temperature": 0.7 }, 43 | "agentBeastMode": { "temperature": 0.7 }, 44 | "fallback": { "temperature": 0 } 45 | } 46 | }, 47 | "openai": { 48 | "default": { 49 | "model": "gpt-4o-mini", 50 | "temperature": 0, 51 | "maxTokens": 8000 52 | }, 53 | "tools": { 54 | "coder": { "temperature": 0.7 }, 55 | "searchGrounding": { "temperature": 0 }, 56 | "dedup": { "temperature": 0.1 }, 57 | "evaluator": {}, 58 | "errorAnalyzer": {}, 59 | "queryRewriter": { "temperature": 0.1 }, 60 | "agent": { "temperature": 0.7 }, 61 | "agentBeastMode": { "temperature": 0.7 }, 62 | "fallback": { "temperature": 0 } 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | environment: 9 | - GEMINI_API_KEY=${GEMINI_API_KEY} 10 | - OPENAI_API_KEY=${OPENAI_API_KEY} 11 | - JINA_API_KEY=${JINA_API_KEY} 12 | - BRAVE_API_KEY=${BRAVE_API_KEY} 13 | ports: 14 | - '3000:3000' 15 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testMatch: ['**/__tests__/**/*.test.ts'], 5 | setupFiles: ['/jest.setup.js'], 6 | }; 7 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | -------------------------------------------------------------------------------- /jina-ai/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /jina-ai/Dockerfile: -------------------------------------------------------------------------------- 1 | # ---- BUILD STAGE ---- 2 | FROM node:22-slim AS builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY ./package*.json ./ 9 | COPY ./jina-ai/package*.json ./jina-ai/ 10 | 11 | # Install dependencies 12 | RUN npm ci 13 | WORKDIR /app/jina-ai 14 | RUN npm ci 15 | 16 | WORKDIR /app 17 | 18 | # Copy application code 19 | COPY ./src ./src 20 | COPY ./tsconfig.json ./tsconfig.json 21 | COPY ./jina-ai/config.json ./ 22 | RUN npm run build 23 | 24 | COPY ./jina-ai/src ./jina-ai/src 25 | COPY ./jina-ai/tsconfig.json ./jina-ai/tsconfig.json 26 | WORKDIR /app/jina-ai 27 | RUN npm run build 28 | 29 | # ---- PRODUCTION STAGE ---- 30 | FROM node:22 AS production 31 | 32 | # Set working directory 33 | WORKDIR /app 34 | 35 | COPY --from=builder /app ./ 36 | # Copy config.json and built files from builder 37 | 38 | WORKDIR /app/jina-ai 39 | 40 | # Set environment variables (Recommended to set at runtime, avoid hardcoding) 41 | ENV GEMINI_API_KEY=${GEMINI_API_KEY} 42 | ENV OPENAI_API_KEY=${OPENAI_API_KEY} 43 | ENV JINA_API_KEY=${JINA_API_KEY} 44 | ENV BRAVE_API_KEY=${BRAVE_API_KEY} 45 | 46 | # Expose the port the app runs on 47 | EXPOSE 3000 48 | 49 | # Set startup command 50 | CMD ["node", "./dist/server.js"] 51 | -------------------------------------------------------------------------------- /jina-ai/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "https_proxy": "", 4 | "OPENAI_BASE_URL": "", 5 | "GEMINI_API_KEY": "", 6 | "OPENAI_API_KEY": "", 7 | "JINA_API_KEY": "", 8 | "BRAVE_API_KEY": "", 9 | "SERPER_API_KEY": "", 10 | "DEFAULT_MODEL_NAME": "" 11 | }, 12 | "defaults": { 13 | "search_provider": "serper", 14 | "llm_provider": "vertex", 15 | "step_sleep": 100 16 | }, 17 | "providers": { 18 | "vertex": { 19 | "createClient": "createGoogleVertex", 20 | "clientConfig": { 21 | "location": "us-central1" 22 | } 23 | }, 24 | "gemini": { 25 | "createClient": "createGoogleGenerativeAI" 26 | }, 27 | "openai": { 28 | "createClient": "createOpenAI", 29 | "clientConfig": { 30 | "compatibility": "strict" 31 | } 32 | } 33 | }, 34 | "models": { 35 | "gemini": { 36 | "default": { 37 | "model": "gemini-2.0-flash", 38 | "temperature": 0, 39 | "maxTokens": 8000 40 | }, 41 | "tools": { 42 | "coder": { "temperature": 0.6, "maxTokens": 1000 }, 43 | "searchGrounding": { "temperature": 0 }, 44 | "dedup": { "temperature": 0.1 }, 45 | "evaluator": { "temperature": 0.6, "maxTokens": 300 }, 46 | "errorAnalyzer": { "maxTokens": 500 }, 47 | "queryRewriter": { "temperature": 0.1, "maxTokens": 500 }, 48 | "agent": { "temperature": 0.6 }, 49 | "agentBeastMode": { "temperature": 0.6 }, 50 | "fallback": { "temperature": 0, "maxTokens": 4000 } 51 | } 52 | }, 53 | "openai": { 54 | "default": { 55 | "model": "gpt-4o-mini", 56 | "temperature": 0, 57 | "maxTokens": 8000 58 | }, 59 | "tools": { 60 | "coder": { "temperature": 0.7 }, 61 | "searchGrounding": { "temperature": 0 }, 62 | "dedup": { "temperature": 0.1 }, 63 | "evaluator": {}, 64 | "errorAnalyzer": {}, 65 | "queryRewriter": { "temperature": 0.1 }, 66 | "agent": { "temperature": 0.7 }, 67 | "agentBeastMode": { "temperature": 0.7 }, 68 | "fallback": { "temperature": 0 } 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /jina-ai/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jina-ai/node-deepresearch", 3 | "version": "1.0.0", 4 | "main": "dist/app.js", 5 | "files": [ 6 | "dist", 7 | "README.md", 8 | "LICENSE" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "npx ts-node src/agent.ts", 13 | "search": "npx ts-node src/test-duck.ts", 14 | "rewrite": "npx ts-node src/tools/query-rewriter.ts", 15 | "lint": "eslint . --ext .ts", 16 | "lint:fix": "eslint . --ext .ts --fix", 17 | "serve": "ts-node src/server.ts", 18 | "eval": "ts-node src/evals/batch-evals.ts", 19 | "test": "jest --testTimeout=30000", 20 | "test:watch": "jest --watch" 21 | }, 22 | "keywords": [], 23 | "author": "Jina AI", 24 | "license": "Apache-2.0", 25 | "description": "", 26 | "dependencies": { 27 | "@ai-sdk/google-vertex": "^2.1.12", 28 | "@google-cloud/firestore": "^7.11.0", 29 | "@google-cloud/storage": "^7.15.1", 30 | "civkit": "^0.8.3-15926cb", 31 | "dayjs": "^1.11.13", 32 | "lodash": "^4.17.21", 33 | "reflect-metadata": "^0.2.2", 34 | "tsyringe": "^4.8.0" 35 | }, 36 | "devDependencies": { 37 | "@types/lodash": "^4.17.15", 38 | "pino-pretty": "^13.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /jina-ai/src/dto/jina-embeddings-auth.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Also, 3 | AuthenticationFailedError, 4 | AuthenticationRequiredError, 5 | DownstreamServiceFailureError, 6 | RPC_CALL_ENVIRONMENT, 7 | ArrayOf, 8 | AutoCastable, 9 | Prop, 10 | } from 'civkit/civ-rpc'; 11 | import { parseJSONText } from 'civkit/vectorize'; 12 | import { htmlEscape } from 'civkit/escape'; 13 | import { marshalErrorLike } from 'civkit/lang'; 14 | 15 | import type express from 'express'; 16 | 17 | import logger from '../lib/logger'; 18 | import { AsyncLocalContext } from '../lib/async-context'; 19 | import { InjectProperty } from '../lib/registry'; 20 | import { JinaEmbeddingsDashboardHTTP } from '../lib/billing'; 21 | import envConfig from '../lib/env-config'; 22 | 23 | import { FirestoreRecord } from '../lib/firestore'; 24 | import _ from 'lodash'; 25 | import { RateLimitDesc } from '../rate-limit'; 26 | 27 | export class JinaWallet extends AutoCastable { 28 | @Prop({ 29 | default: '', 30 | }) 31 | user_id!: string; 32 | 33 | @Prop({ 34 | default: 0, 35 | }) 36 | trial_balance!: number; 37 | 38 | @Prop() 39 | trial_start?: Date; 40 | 41 | @Prop() 42 | trial_end?: Date; 43 | 44 | @Prop({ 45 | default: 0, 46 | }) 47 | regular_balance!: number; 48 | 49 | @Prop({ 50 | default: 0, 51 | }) 52 | total_balance!: number; 53 | } 54 | 55 | export class JinaEmbeddingsTokenAccount extends FirestoreRecord { 56 | static override collectionName = 'embeddingsTokenAccounts'; 57 | 58 | override _id!: string; 59 | 60 | @Prop({ 61 | required: true, 62 | }) 63 | user_id!: string; 64 | 65 | @Prop({ 66 | nullable: true, 67 | type: String, 68 | }) 69 | email?: string; 70 | 71 | @Prop({ 72 | nullable: true, 73 | type: String, 74 | }) 75 | full_name?: string; 76 | 77 | @Prop({ 78 | nullable: true, 79 | type: String, 80 | }) 81 | customer_id?: string; 82 | 83 | @Prop({ 84 | nullable: true, 85 | type: String, 86 | }) 87 | avatar_url?: string; 88 | 89 | // Not keeping sensitive info for now 90 | // @Prop() 91 | // billing_address?: object; 92 | 93 | // @Prop() 94 | // payment_method?: object; 95 | 96 | @Prop({ 97 | required: true, 98 | }) 99 | wallet!: JinaWallet; 100 | 101 | @Prop({ 102 | type: Object, 103 | }) 104 | metadata?: { [k: string]: any }; 105 | 106 | @Prop({ 107 | defaultFactory: () => new Date(), 108 | }) 109 | lastSyncedAt!: Date; 110 | 111 | @Prop({ 112 | dictOf: [ArrayOf(RateLimitDesc)], 113 | }) 114 | customRateLimits?: { [k: string]: RateLimitDesc[] }; 115 | 116 | static patchedFields = []; 117 | 118 | static override from(input: any) { 119 | for (const field of this.patchedFields) { 120 | if (typeof input[field] === 'string') { 121 | input[field] = parseJSONText(input[field]); 122 | } 123 | } 124 | 125 | return super.from(input) as JinaEmbeddingsTokenAccount; 126 | } 127 | 128 | override degradeForFireStore() { 129 | const copy: any = { 130 | ...this, 131 | wallet: { ...this.wallet }, 132 | // Firebase disability 133 | customRateLimits: _.mapValues(this.customRateLimits, (v) => 134 | v.map((x) => ({ ...x })), 135 | ), 136 | }; 137 | 138 | for (const field of (this.constructor as typeof JinaEmbeddingsTokenAccount) 139 | .patchedFields) { 140 | if (typeof copy[field] === 'object') { 141 | copy[field] = JSON.stringify(copy[field]) as any; 142 | } 143 | } 144 | 145 | return copy; 146 | } 147 | 148 | [k: string]: any; 149 | } 150 | 151 | const authDtoLogger = logger.child({ service: 'JinaAuthDTO' }); 152 | 153 | export interface FireBaseHTTPCtx { 154 | req: express.Request; 155 | res: express.Response; 156 | } 157 | 158 | const THE_VERY_SAME_JINA_EMBEDDINGS_CLIENT = new JinaEmbeddingsDashboardHTTP( 159 | envConfig.JINA_EMBEDDINGS_DASHBOARD_API_KEY, 160 | ); 161 | 162 | @Also({ 163 | openapi: { 164 | operation: { 165 | parameters: { 166 | Authorization: { 167 | description: 168 | htmlEscape`Jina Token for authentication.\n\n` + 169 | htmlEscape`- Member of \n\n` + 170 | `- Authorization: Bearer {YOUR_JINA_TOKEN}`, 171 | in: 'header', 172 | schema: { 173 | anyOf: [{ type: 'string', format: 'token' }], 174 | }, 175 | }, 176 | }, 177 | }, 178 | }, 179 | }) 180 | export class JinaEmbeddingsAuthDTO extends AutoCastable { 181 | uid?: string; 182 | bearerToken?: string; 183 | user?: JinaEmbeddingsTokenAccount; 184 | 185 | @InjectProperty(AsyncLocalContext) 186 | ctxMgr!: AsyncLocalContext; 187 | 188 | jinaEmbeddingsDashboard = THE_VERY_SAME_JINA_EMBEDDINGS_CLIENT; 189 | 190 | static override from(input: any) { 191 | const instance = super.from(input) as JinaEmbeddingsAuthDTO; 192 | 193 | const ctx = input[RPC_CALL_ENVIRONMENT]; 194 | 195 | const req = (ctx.rawRequest || ctx.req) as express.Request | undefined; 196 | 197 | if (req) { 198 | const authorization = req.get('authorization'); 199 | 200 | if (authorization) { 201 | const authToken = authorization.split(' ')[1] || authorization; 202 | instance.bearerToken = authToken; 203 | } 204 | } 205 | 206 | if (!instance.bearerToken && input._token) { 207 | instance.bearerToken = input._token; 208 | } 209 | 210 | return instance; 211 | } 212 | 213 | async getBrief(ignoreCache?: boolean | string) { 214 | if (!this.bearerToken) { 215 | throw new AuthenticationRequiredError({ 216 | message: 217 | 'Jina API key is required to authenticate. Please get one from https://jina.ai', 218 | }); 219 | } 220 | 221 | let account; 222 | try { 223 | account = await JinaEmbeddingsTokenAccount.fromFirestore( 224 | this.bearerToken, 225 | ); 226 | } catch (err) { 227 | // FireStore would not accept any string as input and may throw if not happy with it 228 | void 0; 229 | } 230 | 231 | const age = account?.lastSyncedAt 232 | ? Date.now() - account.lastSyncedAt.getTime() 233 | : Infinity; 234 | 235 | if (account && !ignoreCache) { 236 | if (account && age < 180_000) { 237 | this.user = account; 238 | this.uid = this.user?.user_id; 239 | 240 | return account; 241 | } 242 | } 243 | 244 | try { 245 | const r = await this.jinaEmbeddingsDashboard.validateToken( 246 | this.bearerToken, 247 | ); 248 | const brief = r.data; 249 | const draftAccount = JinaEmbeddingsTokenAccount.from({ 250 | ...account, 251 | ...brief, 252 | _id: this.bearerToken, 253 | lastSyncedAt: new Date(), 254 | }); 255 | await JinaEmbeddingsTokenAccount.save( 256 | draftAccount.degradeForFireStore(), 257 | undefined, 258 | { merge: true }, 259 | ); 260 | 261 | this.user = draftAccount; 262 | this.uid = this.user?.user_id; 263 | 264 | return draftAccount; 265 | } catch (err: any) { 266 | authDtoLogger.warn(`Failed to get user brief: ${err}`, { 267 | err: marshalErrorLike(err), 268 | }); 269 | 270 | if (err?.status === 401) { 271 | throw new AuthenticationFailedError({ 272 | message: 'Invalid API key, please get a new one from https://jina.ai', 273 | }); 274 | } 275 | 276 | if (account) { 277 | this.user = account; 278 | this.uid = this.user?.user_id; 279 | 280 | return account; 281 | } 282 | 283 | throw new DownstreamServiceFailureError(`Failed to authenticate: ${err}`); 284 | } 285 | } 286 | 287 | async reportUsage( 288 | tokenCount: number, 289 | mdl: string, 290 | endpoint: string = '/encode', 291 | ) { 292 | const user = await this.assertUser(); 293 | const uid = user.user_id; 294 | user.wallet.total_balance -= tokenCount; 295 | 296 | return this.jinaEmbeddingsDashboard 297 | .reportUsage(this.bearerToken!, { 298 | model_name: mdl, 299 | api_endpoint: endpoint, 300 | consumer: { 301 | id: uid, 302 | user_id: uid, 303 | }, 304 | usage: { 305 | total_tokens: tokenCount, 306 | }, 307 | labels: { 308 | model_name: mdl, 309 | }, 310 | }) 311 | .then((r) => { 312 | JinaEmbeddingsTokenAccount.COLLECTION.doc(this.bearerToken!) 313 | .update({ 314 | 'wallet.total_balance': 315 | JinaEmbeddingsTokenAccount.OPS.increment(-tokenCount), 316 | }) 317 | .catch((err) => { 318 | authDtoLogger.warn(`Failed to update cache for ${uid}: ${err}`, { 319 | err: marshalErrorLike(err), 320 | }); 321 | }); 322 | 323 | return r; 324 | }) 325 | .catch((err) => { 326 | user.wallet.total_balance += tokenCount; 327 | authDtoLogger.warn(`Failed to report usage for ${uid}: ${err}`, { 328 | err: marshalErrorLike(err), 329 | }); 330 | }); 331 | } 332 | 333 | async solveUID() { 334 | if (this.uid) { 335 | this.ctxMgr.set('uid', this.uid); 336 | 337 | return this.uid; 338 | } 339 | 340 | if (this.bearerToken) { 341 | await this.getBrief(); 342 | this.ctxMgr.set('uid', this.uid); 343 | 344 | return this.uid; 345 | } 346 | 347 | return undefined; 348 | } 349 | 350 | async assertUID() { 351 | const uid = await this.solveUID(); 352 | 353 | if (!uid) { 354 | throw new AuthenticationRequiredError('Authentication failed'); 355 | } 356 | 357 | return uid; 358 | } 359 | 360 | async assertUser() { 361 | if (this.user) { 362 | return this.user; 363 | } 364 | 365 | await this.getBrief(); 366 | 367 | return this.user!; 368 | } 369 | 370 | getRateLimits(...tags: string[]) { 371 | const descs = tags 372 | .map((x) => this.user?.customRateLimits?.[x] || []) 373 | .flat() 374 | .filter((x) => x.isEffective()); 375 | 376 | if (descs.length) { 377 | return descs; 378 | } 379 | 380 | return undefined; 381 | } 382 | } 383 | -------------------------------------------------------------------------------- /jina-ai/src/lib/async-context.ts: -------------------------------------------------------------------------------- 1 | import { GlobalAsyncContext } from 'civkit/async-context'; 2 | import { container, singleton } from 'tsyringe'; 3 | 4 | @singleton() 5 | export class AsyncLocalContext extends GlobalAsyncContext {} 6 | 7 | const instance = container.resolve(AsyncLocalContext); 8 | Reflect.set(process, 'asyncLocalContext', instance); 9 | export default instance; 10 | -------------------------------------------------------------------------------- /jina-ai/src/lib/billing.ts: -------------------------------------------------------------------------------- 1 | import { HTTPService } from 'civkit'; 2 | import _ from 'lodash'; 3 | 4 | export interface JinaWallet { 5 | trial_balance: number; 6 | trial_start: Date; 7 | trial_end: Date; 8 | regular_balance: number; 9 | total_balance: number; 10 | } 11 | 12 | export interface JinaUserBrief { 13 | user_id: string; 14 | email: string | null; 15 | full_name: string | null; 16 | customer_id: string | null; 17 | avatar_url?: string; 18 | billing_address: Partial<{ 19 | address: string; 20 | city: string; 21 | state: string; 22 | country: string; 23 | postal_code: string; 24 | }>; 25 | payment_method: Partial<{ 26 | brand: string; 27 | last4: string; 28 | exp_month: number; 29 | exp_year: number; 30 | }>; 31 | wallet: JinaWallet; 32 | metadata: { 33 | [k: string]: any; 34 | }; 35 | } 36 | 37 | export interface JinaUsageReport { 38 | model_name: string; 39 | api_endpoint: string; 40 | consumer: { 41 | user_id: string; 42 | customer_plan?: string; 43 | [k: string]: any; 44 | }; 45 | usage: { 46 | total_tokens: number; 47 | }; 48 | labels: { 49 | user_type?: string; 50 | model_name?: string; 51 | [k: string]: any; 52 | }; 53 | } 54 | 55 | export class JinaEmbeddingsDashboardHTTP extends HTTPService { 56 | name = 'JinaEmbeddingsDashboardHTTP'; 57 | 58 | constructor( 59 | public apiKey: string, 60 | public baseUri: string = 'https://embeddings-dashboard-api.jina.ai/api', 61 | ) { 62 | super(baseUri); 63 | 64 | this.baseOptions.timeout = 30_000; // 30 sec 65 | } 66 | 67 | async authorization(token: string) { 68 | const r = await this.get('/v1/authorization', { 69 | headers: { 70 | Authorization: `Bearer ${token}`, 71 | }, 72 | responseType: 'json', 73 | }); 74 | 75 | return r; 76 | } 77 | 78 | async validateToken(token: string) { 79 | const r = await this.getWithSearchParams( 80 | '/v1/api_key/user', 81 | { 82 | api_key: token, 83 | }, 84 | { 85 | responseType: 'json', 86 | }, 87 | ); 88 | 89 | return r; 90 | } 91 | 92 | async reportUsage(token: string, query: JinaUsageReport) { 93 | const r = await this.postJson('/v1/usage', query, { 94 | headers: { 95 | Authorization: `Bearer ${token}`, 96 | 'x-api-key': this.apiKey, 97 | }, 98 | responseType: 'text', 99 | }); 100 | 101 | return r; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /jina-ai/src/lib/env-config.ts: -------------------------------------------------------------------------------- 1 | import { container, singleton } from 'tsyringe'; 2 | 3 | export const SPECIAL_COMBINED_ENV_KEY = 'ENV_COMBINED'; 4 | const CONF_ENV = [ 5 | 'OPENAI_API_KEY', 6 | 7 | 'ANTHROPIC_API_KEY', 8 | 9 | 'REPLICATE_API_KEY', 10 | 11 | 'GOOGLE_AI_STUDIO_API_KEY', 12 | 13 | 'JINA_EMBEDDINGS_API_KEY', 14 | 15 | 'JINA_EMBEDDINGS_DASHBOARD_API_KEY', 16 | 17 | 'BRAVE_SEARCH_API_KEY', 18 | ] as const; 19 | 20 | @singleton() 21 | export class EnvConfig { 22 | dynamic!: Record; 23 | 24 | combined: Record = {}; 25 | originalEnv: Record = { ...process.env }; 26 | 27 | constructor() { 28 | if (process.env[SPECIAL_COMBINED_ENV_KEY]) { 29 | Object.assign( 30 | this.combined, 31 | JSON.parse( 32 | Buffer.from( 33 | process.env[SPECIAL_COMBINED_ENV_KEY]!, 34 | 'base64', 35 | ).toString('utf-8'), 36 | ), 37 | ); 38 | delete process.env[SPECIAL_COMBINED_ENV_KEY]; 39 | } 40 | 41 | // Static config 42 | for (const x of CONF_ENV) { 43 | const s = this.combined[x] || process.env[x] || ''; 44 | Reflect.set(this, x, s); 45 | if (x in process.env) { 46 | delete process.env[x]; 47 | } 48 | } 49 | 50 | // Dynamic config 51 | this.dynamic = new Proxy( 52 | { 53 | get: (_target: any, prop: string) => { 54 | return this.combined[prop] || process.env[prop] || ''; 55 | }, 56 | }, 57 | {}, 58 | ) as any; 59 | } 60 | } 61 | 62 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 63 | export interface EnvConfig extends Record<(typeof CONF_ENV)[number], string> {} 64 | 65 | const instance = container.resolve(EnvConfig); 66 | export default instance; 67 | -------------------------------------------------------------------------------- /jina-ai/src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationError, 3 | Prop, 4 | RPC_TRANSFER_PROTOCOL_META_SYMBOL, 5 | StatusCode, 6 | } from 'civkit'; 7 | import _ from 'lodash'; 8 | import dayjs from 'dayjs'; 9 | import utc from 'dayjs/plugin/utc'; 10 | 11 | dayjs.extend(utc); 12 | 13 | @StatusCode(50301) 14 | export class ServiceDisabledError extends ApplicationError {} 15 | 16 | @StatusCode(50302) 17 | export class ServiceCrashedError extends ApplicationError {} 18 | 19 | @StatusCode(50303) 20 | export class ServiceNodeResourceDrainError extends ApplicationError {} 21 | 22 | @StatusCode(40104) 23 | export class EmailUnverifiedError extends ApplicationError {} 24 | 25 | @StatusCode(40201) 26 | export class InsufficientCreditsError extends ApplicationError {} 27 | 28 | @StatusCode(40202) 29 | export class FreeFeatureLimitError extends ApplicationError {} 30 | 31 | @StatusCode(40203) 32 | export class InsufficientBalanceError extends ApplicationError {} 33 | 34 | @StatusCode(40903) 35 | export class LockConflictError extends ApplicationError {} 36 | 37 | @StatusCode(40904) 38 | export class BudgetExceededError extends ApplicationError {} 39 | 40 | @StatusCode(45101) 41 | export class HarmfulContentError extends ApplicationError {} 42 | 43 | @StatusCode(45102) 44 | export class SecurityCompromiseError extends ApplicationError {} 45 | 46 | @StatusCode(41201) 47 | export class BatchSizeTooLargeError extends ApplicationError {} 48 | 49 | @StatusCode(42903) 50 | export class RateLimitTriggeredError extends ApplicationError { 51 | @Prop({ 52 | desc: 'Retry after seconds', 53 | }) 54 | retryAfter?: number; 55 | 56 | @Prop({ 57 | desc: 'Retry after date', 58 | }) 59 | retryAfterDate?: Date; 60 | 61 | protected override get [RPC_TRANSFER_PROTOCOL_META_SYMBOL]() { 62 | const retryAfter = this.retryAfter || this.retryAfterDate; 63 | if (!retryAfter) { 64 | return super[RPC_TRANSFER_PROTOCOL_META_SYMBOL]; 65 | } 66 | 67 | return _.merge(_.cloneDeep(super[RPC_TRANSFER_PROTOCOL_META_SYMBOL]), { 68 | headers: { 69 | 'Retry-After': `${retryAfter instanceof Date ? dayjs(retryAfter).utc().format('ddd, DD MMM YYYY HH:mm:ss [GMT]') : retryAfter}`, 70 | }, 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /jina-ai/src/lib/firestore.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { AutoCastable, Prop, RPC_MARSHAL } from 'civkit/civ-rpc'; 3 | import { 4 | Firestore, 5 | FieldValue, 6 | DocumentReference, 7 | Query, 8 | Timestamp, 9 | SetOptions, 10 | DocumentSnapshot, 11 | } from '@google-cloud/firestore'; 12 | import { Storage } from '@google-cloud/storage'; 13 | 14 | export const firebaseDefaultBucket = new Storage().bucket( 15 | `${process.env.GCLOUD_PROJECT}.appspot.com`, 16 | ); 17 | 18 | // Firestore doesn't support JavaScript objects with custom prototypes (i.e. objects that were created via the \"new\" operator) 19 | function patchFireStoreArrogance(func: Function) { 20 | return function (this: unknown) { 21 | const origObjectGetPrototype = Object.getPrototypeOf; 22 | Object.getPrototypeOf = function (x) { 23 | const r = origObjectGetPrototype.call(this, x); 24 | if (!r) { 25 | return r; 26 | } 27 | return Object.prototype; 28 | }; 29 | try { 30 | return func.call(this, ...arguments); 31 | } finally { 32 | Object.getPrototypeOf = origObjectGetPrototype; 33 | } 34 | }; 35 | } 36 | 37 | Reflect.set( 38 | DocumentReference.prototype, 39 | 'set', 40 | patchFireStoreArrogance(Reflect.get(DocumentReference.prototype, 'set')), 41 | ); 42 | Reflect.set( 43 | DocumentSnapshot, 44 | 'fromObject', 45 | patchFireStoreArrogance(Reflect.get(DocumentSnapshot, 'fromObject')), 46 | ); 47 | 48 | function mapValuesDeep(v: any, fn: (i: any) => any): any { 49 | if (_.isPlainObject(v)) { 50 | return _.mapValues(v, (i) => mapValuesDeep(i, fn)); 51 | } else if (_.isArray(v)) { 52 | return v.map((i) => mapValuesDeep(i, fn)); 53 | } else { 54 | return fn(v); 55 | } 56 | } 57 | 58 | export type Constructor = { new (...args: any[]): T }; 59 | export type Constructed = 60 | T extends Partial ? U : T extends object ? T : object; 61 | 62 | export function fromFirestore( 63 | this: Constructor, 64 | id: string, 65 | overrideCollection?: string, 66 | ): Promise; 67 | export async function fromFirestore( 68 | this: any, 69 | id: string, 70 | overrideCollection?: string, 71 | ) { 72 | const collection = overrideCollection || this.collectionName; 73 | if (!collection) { 74 | throw new Error(`Missing collection name to construct ${this.name}`); 75 | } 76 | 77 | const ref = this.DB.collection(overrideCollection || this.collectionName).doc( 78 | id, 79 | ); 80 | 81 | const ptr = await ref.get(); 82 | 83 | if (!ptr.exists) { 84 | return undefined; 85 | } 86 | 87 | const doc = this.from( 88 | // Fixes non-native firebase types 89 | mapValuesDeep(ptr.data(), (i: any) => { 90 | if (i instanceof Timestamp) { 91 | return i.toDate(); 92 | } 93 | 94 | return i; 95 | }), 96 | ); 97 | 98 | Object.defineProperty(doc, '_ref', { value: ref, enumerable: false }); 99 | Object.defineProperty(doc, '_id', { value: ptr.id, enumerable: true }); 100 | 101 | return doc; 102 | } 103 | 104 | export function fromFirestoreQuery( 105 | this: Constructor, 106 | query: Query, 107 | ): Promise; 108 | export async function fromFirestoreQuery(this: any, query: Query) { 109 | const ptr = await query.get(); 110 | 111 | if (ptr.docs.length) { 112 | return ptr.docs.map((doc) => { 113 | const r = this.from( 114 | mapValuesDeep(doc.data(), (i: any) => { 115 | if (i instanceof Timestamp) { 116 | return i.toDate(); 117 | } 118 | 119 | return i; 120 | }), 121 | ); 122 | Object.defineProperty(r, '_ref', { value: doc.ref, enumerable: false }); 123 | Object.defineProperty(r, '_id', { value: doc.id, enumerable: true }); 124 | 125 | return r; 126 | }); 127 | } 128 | 129 | return []; 130 | } 131 | 132 | export function setToFirestore( 133 | this: Constructor, 134 | doc: T, 135 | overrideCollection?: string, 136 | setOptions?: SetOptions, 137 | ): Promise; 138 | export async function setToFirestore( 139 | this: any, 140 | doc: any, 141 | overrideCollection?: string, 142 | setOptions?: SetOptions, 143 | ) { 144 | let ref: DocumentReference = doc._ref; 145 | if (!ref) { 146 | const collection = overrideCollection || this.collectionName; 147 | if (!collection) { 148 | throw new Error(`Missing collection name to construct ${this.name}`); 149 | } 150 | 151 | const predefinedId = doc._id || undefined; 152 | const hdl = this.DB.collection(overrideCollection || this.collectionName); 153 | ref = predefinedId ? hdl.doc(predefinedId) : hdl.doc(); 154 | 155 | Object.defineProperty(doc, '_ref', { value: ref, enumerable: false }); 156 | Object.defineProperty(doc, '_id', { value: ref.id, enumerable: true }); 157 | } 158 | 159 | await ref.set(doc, { merge: true, ...setOptions }); 160 | 161 | return doc; 162 | } 163 | 164 | export function deleteQueryBatch( 165 | this: Constructor, 166 | query: Query, 167 | ): Promise; 168 | export async function deleteQueryBatch(this: any, query: Query) { 169 | const snapshot = await query.get(); 170 | 171 | const batchSize = snapshot.size; 172 | if (batchSize === 0) { 173 | return; 174 | } 175 | 176 | // Delete documents in a batch 177 | const batch = this.DB.batch(); 178 | snapshot.docs.forEach((doc) => { 179 | batch.delete(doc.ref); 180 | }); 181 | await batch.commit(); 182 | 183 | process.nextTick(() => { 184 | this.deleteQueryBatch(query); 185 | }); 186 | } 187 | 188 | export function fromFirestoreDoc( 189 | this: Constructor, 190 | snapshot: DocumentSnapshot, 191 | ): T | undefined; 192 | export function fromFirestoreDoc(this: any, snapshot: DocumentSnapshot) { 193 | const doc = this.from( 194 | // Fixes non-native firebase types 195 | mapValuesDeep(snapshot.data(), (i: any) => { 196 | if (i instanceof Timestamp) { 197 | return i.toDate(); 198 | } 199 | 200 | return i; 201 | }), 202 | ); 203 | 204 | Object.defineProperty(doc, '_ref', { 205 | value: snapshot.ref, 206 | enumerable: false, 207 | }); 208 | Object.defineProperty(doc, '_id', { value: snapshot.id, enumerable: true }); 209 | 210 | return doc; 211 | } 212 | const defaultFireStore = new Firestore({ 213 | projectId: process.env.GCLOUD_PROJECT, 214 | }); 215 | export class FirestoreRecord extends AutoCastable { 216 | static collectionName?: string; 217 | static OPS = FieldValue; 218 | static DB = defaultFireStore; 219 | static get COLLECTION() { 220 | if (!this.collectionName) { 221 | throw new Error('Not implemented'); 222 | } 223 | 224 | return this.DB.collection(this.collectionName); 225 | } 226 | 227 | @Prop() 228 | _id?: string; 229 | _ref?: DocumentReference>>; 230 | 231 | static fromFirestore = fromFirestore; 232 | static fromFirestoreDoc = fromFirestoreDoc; 233 | static fromFirestoreQuery = fromFirestoreQuery; 234 | 235 | static save = setToFirestore; 236 | static deleteQueryBatch = deleteQueryBatch; 237 | 238 | [RPC_MARSHAL]() { 239 | return { 240 | ...this, 241 | _id: this._id, 242 | _ref: this._ref?.path, 243 | }; 244 | } 245 | 246 | degradeForFireStore(): this { 247 | return JSON.parse( 248 | JSON.stringify(this, function (k, v) { 249 | if (k === '') { 250 | return v; 251 | } 252 | if ( 253 | typeof v === 'object' && 254 | v && 255 | typeof v.degradeForFireStore === 'function' 256 | ) { 257 | return v.degradeForFireStore(); 258 | } 259 | 260 | return v; 261 | }), 262 | ); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /jina-ai/src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { AbstractPinoLogger } from 'civkit/pino-logger'; 2 | import { singleton, container } from 'tsyringe'; 3 | import { threadId } from 'node:worker_threads'; 4 | import { getTraceCtx } from 'civkit/async-context'; 5 | 6 | const levelToSeverityMap: { [k: string]: string | undefined } = { 7 | trace: 'DEFAULT', 8 | debug: 'DEBUG', 9 | info: 'INFO', 10 | warn: 'WARNING', 11 | error: 'ERROR', 12 | fatal: 'CRITICAL', 13 | }; 14 | 15 | @singleton() 16 | export class GlobalLogger extends AbstractPinoLogger { 17 | loggerOptions = { 18 | level: 'debug', 19 | base: { 20 | tid: threadId, 21 | }, 22 | }; 23 | 24 | override init(): void { 25 | if (process.env['NODE_ENV']?.startsWith('prod')) { 26 | super.init(process.stdout); 27 | } else { 28 | const PinoPretty = require('pino-pretty').PinoPretty; 29 | super.init( 30 | PinoPretty({ 31 | singleLine: true, 32 | colorize: true, 33 | messageFormat(log: any, messageKey: any) { 34 | return `${log['tid'] ? `[${log['tid']}]` : ''}[${log['service'] || 'ROOT'}] ${log[messageKey]}`; 35 | }, 36 | }), 37 | ); 38 | } 39 | 40 | this.emit('ready'); 41 | } 42 | 43 | override log(...args: any[]) { 44 | const [levelObj, ...rest] = args; 45 | const severity = levelToSeverityMap[levelObj?.level]; 46 | const traceCtx = getTraceCtx(); 47 | const patched: any = { ...levelObj, severity }; 48 | if (traceCtx?.traceId && process.env['GCLOUD_PROJECT']) { 49 | patched['logging.googleapis.com/trace'] = 50 | `projects/${process.env['GCLOUD_PROJECT']}/traces/${traceCtx.traceId}`; 51 | } 52 | return super.log(patched, ...rest); 53 | } 54 | } 55 | 56 | const instance = container.resolve(GlobalLogger); 57 | export default instance; 58 | -------------------------------------------------------------------------------- /jina-ai/src/lib/registry.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | import { propertyInjectorFactory } from 'civkit/property-injector'; 3 | 4 | export const InjectProperty = propertyInjectorFactory(container); 5 | -------------------------------------------------------------------------------- /jina-ai/src/patch-express.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApplicationError, 3 | OperationNotAllowedError, 4 | Prop, 5 | RPC_CALL_ENVIRONMENT, 6 | } from 'civkit/civ-rpc'; 7 | import { marshalErrorLike } from 'civkit/lang'; 8 | import { randomUUID } from 'crypto'; 9 | import { once } from 'events'; 10 | import type { NextFunction, Request, Response } from 'express'; 11 | 12 | import { JinaEmbeddingsAuthDTO } from './dto/jina-embeddings-auth'; 13 | import rateLimitControl, { 14 | API_CALL_STATUS, 15 | APICall, 16 | RateLimitDesc, 17 | } from './rate-limit'; 18 | import asyncLocalContext from './lib/async-context'; 19 | import globalLogger from './lib/logger'; 20 | import { InsufficientBalanceError } from './lib/errors'; 21 | import { firebaseDefaultBucket, FirestoreRecord } from './lib/firestore'; 22 | import cors from 'cors'; 23 | 24 | globalLogger.serviceReady(); 25 | const logger = globalLogger.child({ service: 'JinaAISaaSMiddleware' }); 26 | const appName = 'DEEPRESEARCH'; 27 | 28 | export class KnowledgeItem extends FirestoreRecord { 29 | static override collectionName = 'knowledgeItems'; 30 | 31 | @Prop({ 32 | required: true, 33 | }) 34 | traceId!: string; 35 | 36 | @Prop({ 37 | required: true, 38 | }) 39 | uid!: string; 40 | 41 | @Prop({ 42 | default: '', 43 | }) 44 | question!: string; 45 | 46 | @Prop({ 47 | default: '', 48 | }) 49 | answer!: string; 50 | 51 | @Prop({ 52 | default: '', 53 | }) 54 | type!: string; 55 | 56 | @Prop({ 57 | arrayOf: Object, 58 | default: [], 59 | }) 60 | references!: any[]; 61 | 62 | @Prop({ 63 | defaultFactory: () => new Date(), 64 | }) 65 | createdAt!: Date; 66 | 67 | @Prop({ 68 | defaultFactory: () => new Date(), 69 | }) 70 | updatedAt!: Date; 71 | } 72 | const corsMiddleware = cors(); 73 | export const jinaAiMiddleware = ( 74 | req: Request, 75 | res: Response, 76 | next: NextFunction, 77 | ) => { 78 | if (req.path === '/ping') { 79 | res.status(200).end('pone'); 80 | return; 81 | } 82 | if (req.path.startsWith('/v1/models')) { 83 | next(); 84 | return; 85 | } 86 | if (req.method !== 'POST' && req.method !== 'GET') { 87 | next(); 88 | return; 89 | } 90 | asyncLocalContext.run(async () => { 91 | const googleTraceId = req.get('x-cloud-trace-context')?.split('/')?.[0]; 92 | const ctx = asyncLocalContext.ctx; 93 | ctx.traceId = 94 | req.get('x-request-id') || 95 | req.get('request-id') || 96 | googleTraceId || 97 | randomUUID(); 98 | ctx.traceT0 = new Date(); 99 | ctx.ip = req?.ip; 100 | 101 | try { 102 | const authDto = JinaEmbeddingsAuthDTO.from({ 103 | [RPC_CALL_ENVIRONMENT]: { req, res }, 104 | }); 105 | 106 | const uid = await authDto.solveUID(); 107 | if (!uid && !ctx.ip) { 108 | throw new OperationNotAllowedError( 109 | `Missing IP information for anonymous user`, 110 | ); 111 | } 112 | let rateLimitPolicy; 113 | if (uid) { 114 | const user = await authDto.assertUser(); 115 | if (!(user.wallet.total_balance > 0)) { 116 | throw new InsufficientBalanceError( 117 | `Account balance not enough to run this query, please recharge.`, 118 | ); 119 | } 120 | rateLimitPolicy = authDto.getRateLimits(appName) || [ 121 | parseInt(user.metadata?.speed_level) >= 2 122 | ? RateLimitDesc.from({ 123 | occurrence: 30, 124 | periodSeconds: 60, 125 | }) 126 | : RateLimitDesc.from({ 127 | occurrence: 10, 128 | periodSeconds: 60, 129 | }), 130 | ]; 131 | } else { 132 | rateLimitPolicy = [ 133 | RateLimitDesc.from({ 134 | occurrence: 3, 135 | periodSeconds: 60, 136 | }), 137 | ]; 138 | } 139 | 140 | const criterions = rateLimitPolicy.map((c) => 141 | rateLimitControl.rateLimitDescToCriterion(c), 142 | ); 143 | await Promise.all( 144 | criterions.map(([pointInTime, n]) => 145 | uid 146 | ? rateLimitControl.assertUidPeriodicLimit( 147 | uid, 148 | pointInTime, 149 | n, 150 | appName, 151 | ) 152 | : rateLimitControl.assertIPPeriodicLimit( 153 | ctx.ip!, 154 | pointInTime, 155 | n, 156 | appName, 157 | ), 158 | ), 159 | ); 160 | const draftApiCall: Partial = { tags: [appName] }; 161 | if (uid) { 162 | draftApiCall.uid = uid; 163 | } else { 164 | draftApiCall.ip = ctx.ip; 165 | } 166 | 167 | const apiRoll = rateLimitControl.record(draftApiCall); 168 | apiRoll 169 | .save() 170 | .catch((err) => 171 | logger.warn(`Failed to save rate limit record`, { 172 | err: marshalErrorLike(err), 173 | }), 174 | ); 175 | 176 | const pResClose = once(res, 'close'); 177 | 178 | next(); 179 | 180 | await pResClose; 181 | const chargeAmount = ctx.chargeAmount; 182 | if (chargeAmount) { 183 | authDto.reportUsage(chargeAmount, `reader-${appName}`).catch((err) => { 184 | logger.warn(`Unable to report usage for ${uid || ctx.ip}`, { 185 | err: marshalErrorLike(err), 186 | }); 187 | }); 188 | apiRoll.chargeAmount = chargeAmount; 189 | } 190 | apiRoll.status = 191 | res.statusCode === 200 192 | ? API_CALL_STATUS.SUCCESS 193 | : API_CALL_STATUS.ERROR; 194 | apiRoll 195 | .save() 196 | .catch((err) => 197 | logger.warn(`Failed to save rate limit record`, { 198 | err: marshalErrorLike(err), 199 | }), 200 | ); 201 | logger.info( 202 | `HTTP ${res.statusCode} for request ${ctx.traceId} after ${Date.now() - ctx.traceT0.valueOf()}ms`, 203 | { 204 | uid, 205 | ip: ctx.ip, 206 | chargeAmount, 207 | }, 208 | ); 209 | 210 | if (uid && ctx.promptContext?.knowledge?.length) { 211 | Promise.all( 212 | ctx.promptContext.knowledge.map((x: any) => 213 | KnowledgeItem.save( 214 | KnowledgeItem.from({ 215 | ...x, 216 | uid, 217 | traceId: ctx.traceId, 218 | }), 219 | ), 220 | ), 221 | ).catch((err: any) => { 222 | logger.warn(`Failed to save knowledge`, { 223 | err: marshalErrorLike(err), 224 | }); 225 | }); 226 | } 227 | if (ctx.promptContext) { 228 | const patchedCtx = { ...ctx.promptContext }; 229 | if (Array.isArray(patchedCtx.context)) { 230 | patchedCtx.context = patchedCtx.context.map((x: object) => ({ 231 | ...x, 232 | result: undefined, 233 | })); 234 | } 235 | 236 | firebaseDefaultBucket 237 | .file(`promptContext/${ctx.traceId}.json`) 238 | .save(JSON.stringify(patchedCtx), { 239 | metadata: { 240 | contentType: 'application/json', 241 | }, 242 | }) 243 | .catch((err: any) => { 244 | logger.warn(`Failed to save promptContext`, { 245 | err: marshalErrorLike(err), 246 | }); 247 | }); 248 | } 249 | } catch (err: any) { 250 | if (!res.headersSent) { 251 | corsMiddleware(req, res, () => 'noop'); 252 | if (err instanceof ApplicationError) { 253 | res 254 | .status(parseInt(err.code as string) || 500) 255 | .json({ error: err.message }); 256 | 257 | return; 258 | } 259 | 260 | res.status(500).json({ error: 'Internal' }); 261 | } 262 | 263 | logger.error(`Error in billing middleware`, { 264 | err: marshalErrorLike(err), 265 | }); 266 | if (err.stack) { 267 | logger.error(err.stack); 268 | } 269 | } 270 | }); 271 | }; 272 | -------------------------------------------------------------------------------- /jina-ai/src/rate-limit.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AutoCastable, 3 | ResourcePolicyDenyError, 4 | Also, 5 | Prop, 6 | } from 'civkit/civ-rpc'; 7 | import { AsyncService } from 'civkit/async-service'; 8 | import { getTraceId } from 'civkit/async-context'; 9 | import { singleton, container } from 'tsyringe'; 10 | 11 | import { RateLimitTriggeredError } from './lib/errors'; 12 | import { FirestoreRecord } from './lib/firestore'; 13 | import { GlobalLogger } from './lib/logger'; 14 | 15 | export enum API_CALL_STATUS { 16 | SUCCESS = 'success', 17 | ERROR = 'error', 18 | PENDING = 'pending', 19 | } 20 | 21 | @Also({ dictOf: Object }) 22 | export class APICall extends FirestoreRecord { 23 | static override collectionName = 'apiRoll'; 24 | 25 | @Prop({ 26 | required: true, 27 | defaultFactory: () => getTraceId(), 28 | }) 29 | traceId!: string; 30 | 31 | @Prop() 32 | uid?: string; 33 | 34 | @Prop() 35 | ip?: string; 36 | 37 | @Prop({ 38 | arrayOf: String, 39 | default: [], 40 | }) 41 | tags!: string[]; 42 | 43 | @Prop({ 44 | required: true, 45 | defaultFactory: () => new Date(), 46 | }) 47 | createdAt!: Date; 48 | 49 | @Prop() 50 | completedAt?: Date; 51 | 52 | @Prop({ 53 | required: true, 54 | default: API_CALL_STATUS.PENDING, 55 | }) 56 | status!: API_CALL_STATUS; 57 | 58 | @Prop({ 59 | required: true, 60 | defaultFactory: () => new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), 61 | }) 62 | expireAt!: Date; 63 | 64 | [k: string]: any; 65 | 66 | tag(...tags: string[]) { 67 | for (const t of tags) { 68 | if (!this.tags.includes(t)) { 69 | this.tags.push(t); 70 | } 71 | } 72 | } 73 | 74 | save() { 75 | return (this.constructor as typeof APICall).save(this); 76 | } 77 | } 78 | 79 | export class RateLimitDesc extends AutoCastable { 80 | @Prop({ 81 | default: 1000, 82 | }) 83 | occurrence!: number; 84 | 85 | @Prop({ 86 | default: 3600, 87 | }) 88 | periodSeconds!: number; 89 | 90 | @Prop() 91 | notBefore?: Date; 92 | 93 | @Prop() 94 | notAfter?: Date; 95 | 96 | isEffective() { 97 | const now = new Date(); 98 | if (this.notBefore && this.notBefore > now) { 99 | return false; 100 | } 101 | if (this.notAfter && this.notAfter < now) { 102 | return false; 103 | } 104 | 105 | return true; 106 | } 107 | } 108 | 109 | @singleton() 110 | export class RateLimitControl extends AsyncService { 111 | logger = this.globalLogger.child({ service: this.constructor.name }); 112 | 113 | constructor(protected globalLogger: GlobalLogger) { 114 | super(...arguments); 115 | } 116 | 117 | override async init() { 118 | await this.dependencyReady(); 119 | 120 | this.emit('ready'); 121 | } 122 | 123 | async queryByUid(uid: string, pointInTime: Date, ...tags: string[]) { 124 | let q = APICall.COLLECTION.orderBy('createdAt', 'asc') 125 | .where('createdAt', '>=', pointInTime) 126 | .where('status', 'in', [API_CALL_STATUS.SUCCESS, API_CALL_STATUS.PENDING]) 127 | .where('uid', '==', uid); 128 | if (tags.length) { 129 | q = q.where('tags', 'array-contains-any', tags); 130 | } 131 | 132 | return APICall.fromFirestoreQuery(q); 133 | } 134 | 135 | async queryByIp(ip: string, pointInTime: Date, ...tags: string[]) { 136 | let q = APICall.COLLECTION.orderBy('createdAt', 'asc') 137 | .where('createdAt', '>=', pointInTime) 138 | .where('status', 'in', [API_CALL_STATUS.SUCCESS, API_CALL_STATUS.PENDING]) 139 | .where('ip', '==', ip); 140 | if (tags.length) { 141 | q = q.where('tags', 'array-contains-any', tags); 142 | } 143 | 144 | return APICall.fromFirestoreQuery(q); 145 | } 146 | 147 | async assertUidPeriodicLimit( 148 | uid: string, 149 | pointInTime: Date, 150 | limit: number, 151 | ...tags: string[] 152 | ) { 153 | if (limit <= 0) { 154 | throw new ResourcePolicyDenyError( 155 | `This UID(${uid}) is not allowed to call this endpoint (rate limit quota is 0).`, 156 | ); 157 | } 158 | 159 | let q = APICall.COLLECTION.orderBy('createdAt', 'asc') 160 | .where('createdAt', '>=', pointInTime) 161 | .where('status', 'in', [API_CALL_STATUS.SUCCESS, API_CALL_STATUS.PENDING]) 162 | .where('uid', '==', uid); 163 | if (tags.length) { 164 | q = q.where('tags', 'array-contains-any', tags); 165 | } 166 | const count = (await q.count().get()).data().count; 167 | 168 | if (count >= limit) { 169 | const r = await APICall.fromFirestoreQuery(q.limit(1)); 170 | const [r1] = r; 171 | 172 | const dtMs = Math.abs(r1.createdAt?.valueOf() - pointInTime.valueOf()); 173 | const dtSec = Math.ceil(dtMs / 1000); 174 | 175 | throw RateLimitTriggeredError.from({ 176 | message: `Per UID rate limit exceeded (${tags.join(',') || 'called'} ${limit} times since ${pointInTime})`, 177 | retryAfter: dtSec, 178 | }); 179 | } 180 | 181 | return count + 1; 182 | } 183 | 184 | async assertIPPeriodicLimit( 185 | ip: string, 186 | pointInTime: Date, 187 | limit: number, 188 | ...tags: string[] 189 | ) { 190 | let q = APICall.COLLECTION.orderBy('createdAt', 'asc') 191 | .where('createdAt', '>=', pointInTime) 192 | .where('status', 'in', [API_CALL_STATUS.SUCCESS, API_CALL_STATUS.PENDING]) 193 | .where('ip', '==', ip); 194 | if (tags.length) { 195 | q = q.where('tags', 'array-contains-any', tags); 196 | } 197 | 198 | const count = (await q.count().get()).data().count; 199 | 200 | if (count >= limit) { 201 | const r = await APICall.fromFirestoreQuery(q.limit(1)); 202 | const [r1] = r; 203 | 204 | const dtMs = Math.abs(r1.createdAt?.valueOf() - pointInTime.valueOf()); 205 | const dtSec = Math.ceil(dtMs / 1000); 206 | 207 | throw RateLimitTriggeredError.from({ 208 | message: `Per IP rate limit exceeded (${tags.join(',') || 'called'} ${limit} times since ${pointInTime})`, 209 | retryAfter: dtSec, 210 | }); 211 | } 212 | 213 | return count + 1; 214 | } 215 | 216 | record(partialRecord: Partial) { 217 | const record = APICall.from(partialRecord); 218 | const newId = APICall.COLLECTION.doc().id; 219 | record._id = newId; 220 | 221 | return record; 222 | } 223 | 224 | // async simpleRPCUidBasedLimit(rpcReflect: RPCReflection, uid: string, tags: string[] = [], 225 | // ...inputCriterion: RateLimitDesc[] | [Date, number][]) { 226 | // const criterion = inputCriterion.map((c) => { return Array.isArray(c) ? c : this.rateLimitDescToCriterion(c); }); 227 | 228 | // await Promise.all(criterion.map(([pointInTime, n]) => 229 | // this.assertUidPeriodicLimit(uid, pointInTime, n, ...tags))); 230 | 231 | // const r = this.record({ 232 | // uid, 233 | // tags, 234 | // }); 235 | 236 | // r.save().catch((err) => this.logger.warn(`Failed to save rate limit record`, { err })); 237 | // rpcReflect.then(() => { 238 | // r.status = API_CALL_STATUS.SUCCESS; 239 | // r.save() 240 | // .catch((err) => this.logger.warn(`Failed to save rate limit record`, { err })); 241 | // }); 242 | // rpcReflect.catch((err) => { 243 | // r.status = API_CALL_STATUS.ERROR; 244 | // r.error = err.toString(); 245 | // r.save() 246 | // .catch((err) => this.logger.warn(`Failed to save rate limit record`, { err })); 247 | // }); 248 | 249 | // return r; 250 | // } 251 | 252 | rateLimitDescToCriterion(rateLimitDesc: RateLimitDesc) { 253 | return [ 254 | new Date(Date.now() - rateLimitDesc.periodSeconds * 1000), 255 | rateLimitDesc.occurrence, 256 | ] as [Date, number]; 257 | } 258 | 259 | // async simpleRpcIPBasedLimit(rpcReflect: RPCReflection, ip: string, tags: string[] = [], 260 | // ...inputCriterion: RateLimitDesc[] | [Date, number][]) { 261 | // const criterion = inputCriterion.map((c) => { return Array.isArray(c) ? c : this.rateLimitDescToCriterion(c); }); 262 | // await Promise.all(criterion.map(([pointInTime, n]) => 263 | // this.assertIPPeriodicLimit(ip, pointInTime, n, ...tags))); 264 | 265 | // const r = this.record({ 266 | // ip, 267 | // tags, 268 | // }); 269 | 270 | // r.save().catch((err) => this.logger.warn(`Failed to save rate limit record`, { err })); 271 | // rpcReflect.then(() => { 272 | // r.status = API_CALL_STATUS.SUCCESS; 273 | // r.save() 274 | // .catch((err) => this.logger.warn(`Failed to save rate limit record`, { err })); 275 | // }); 276 | // rpcReflect.catch((err) => { 277 | // r.status = API_CALL_STATUS.ERROR; 278 | // r.error = err.toString(); 279 | // r.save() 280 | // .catch((err) => this.logger.warn(`Failed to save rate limit record`, { err })); 281 | // }); 282 | 283 | // return r; 284 | // } 285 | } 286 | 287 | const instance = container.resolve(RateLimitControl); 288 | 289 | export default instance; 290 | -------------------------------------------------------------------------------- /jina-ai/src/server.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import express from 'express'; 3 | import { jinaAiMiddleware } from './patch-express'; 4 | import { Server } from 'http'; 5 | 6 | const app = require('../..').default; 7 | 8 | const rootApp = express(); 9 | rootApp.set('trust proxy', true); 10 | rootApp.use(jinaAiMiddleware, app); 11 | 12 | const port = process.env.PORT || 3000; 13 | 14 | let server: Server | undefined; 15 | // Export server startup function for better testing 16 | export function startServer() { 17 | return rootApp.listen(port, () => { 18 | console.log(`Server running at http://localhost:${port}`); 19 | }); 20 | } 21 | 22 | // Start server if running directly 23 | if (process.env.NODE_ENV !== 'test') { 24 | server = startServer(); 25 | } 26 | 27 | process.on('unhandledRejection', (_err) => `Is false alarm`); 28 | 29 | process.on('uncaughtException', (err) => { 30 | console.log('Uncaught exception', err); 31 | 32 | // Looks like Firebase runtime does not handle error properly. 33 | // Make sure to quit the process. 34 | process.nextTick(() => process.exit(1)); 35 | console.error('Uncaught exception, process quit.'); 36 | throw err; 37 | }); 38 | 39 | const sigHandler = (signal: string) => { 40 | console.log(`Received ${signal}, exiting...`); 41 | if (server && server.listening) { 42 | console.log(`Shutting down gracefully...`); 43 | console.log(`Waiting for the server to drain and close...`); 44 | server.close((err) => { 45 | if (err) { 46 | console.error('Error while closing server', err); 47 | return; 48 | } 49 | process.exit(0); 50 | }); 51 | server.closeIdleConnections(); 52 | } 53 | }; 54 | process.on('SIGTERM', sigHandler); 55 | process.on('SIGINT', sigHandler); 56 | -------------------------------------------------------------------------------- /jina-ai/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "node16", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "resolveJsonModule": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-deepresearch", 3 | "version": "1.0.0", 4 | "main": "dist/app.js", 5 | "files": [ 6 | "dist", 7 | "README.md", 8 | "LICENSE" 9 | ], 10 | "scripts": { 11 | "build": "tsc", 12 | "dev": "npx ts-node src/agent.ts", 13 | "search": "npx ts-node src/test-duck.ts", 14 | "rewrite": "npx ts-node src/tools/query-rewriter.ts", 15 | "lint": "eslint . --ext .ts", 16 | "lint:fix": "eslint . --ext .ts --fix", 17 | "serve": "ts-node src/server.ts", 18 | "start": "ts-node src/server.ts", 19 | "eval": "ts-node src/evals/batch-evals.ts", 20 | "test": "jest --testTimeout=30000", 21 | "test:watch": "jest --watch", 22 | "test:docker": "jest src/__tests__/docker.test.ts --testTimeout=300000" 23 | }, 24 | "keywords": [], 25 | "author": "Jina AI", 26 | "license": "Apache-2.0", 27 | "description": "", 28 | "dependencies": { 29 | "@ai-sdk/google": "^1.0.0", 30 | "@ai-sdk/openai": "^1.1.9", 31 | "ai": "^4.1.26", 32 | "axios": "^1.7.9", 33 | "commander": "^13.1.0", 34 | "cors": "^2.8.5", 35 | "dedent": "^1.5.3", 36 | "dotenv": "^16.4.7", 37 | "duck-duck-scrape": "^2.2.7", 38 | "express": "^4.21.2", 39 | "node-fetch": "^3.3.2", 40 | "react": "^19.0.0", 41 | "undici": "^7.3.0", 42 | "zod": "^3.22.4", 43 | "zod-to-json-schema": "^3.24.1" 44 | }, 45 | "devDependencies": { 46 | "@types/commander": "^2.12.0", 47 | "@types/cors": "^2.8.17", 48 | "@types/express": "^5.0.0", 49 | "@types/jest": "^29.5.14", 50 | "@types/node": "^22.10.10", 51 | "@types/node-fetch": "^2.6.12", 52 | "@types/react": "^19.0.10", 53 | "@types/supertest": "^6.0.2", 54 | "@typescript-eslint/eslint-plugin": "^7.0.1", 55 | "@typescript-eslint/parser": "^7.0.1", 56 | "eslint": "^8.56.0", 57 | "jest": "^29.7.0", 58 | "prettier": "^3.5.2", 59 | "supertest": "^7.0.0", 60 | "ts-jest": "^29.2.5", 61 | "ts-node": "^10.9.2", 62 | "typescript": "^5.7.3" 63 | }, 64 | "optionalDependencies": { 65 | "@ai-sdk/google-vertex": "^2.1.12" 66 | }, 67 | "prettier": { 68 | "semi": true, 69 | "singleQuote": true, 70 | "trailingComma": "all", 71 | "printWidth": 80 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/__tests__/agent.test.ts: -------------------------------------------------------------------------------- 1 | import { getResponse } from '../agent'; 2 | import { generateObject } from 'ai'; 3 | import { search } from '../tools/jina-search'; 4 | import { readUrl } from '../tools/read'; 5 | 6 | // Mock external dependencies 7 | jest.mock('ai', () => ({ 8 | generateObject: jest.fn(), 9 | })); 10 | 11 | jest.mock('../tools/jina-search', () => ({ 12 | search: jest.fn(), 13 | })); 14 | 15 | jest.mock('../tools/read', () => ({ 16 | readUrl: jest.fn(), 17 | })); 18 | 19 | describe('getResponse', () => { 20 | beforeEach(() => { 21 | // Mock generateObject to return a valid response 22 | (generateObject as jest.Mock).mockResolvedValue({ 23 | object: { 24 | action: 'answer', 25 | answer: 'mocked response', 26 | references: [], 27 | think: 'mocked thought', 28 | }, 29 | usage: { totalTokens: 100 }, 30 | }); 31 | 32 | // Mock search to return empty results 33 | (search as jest.Mock).mockResolvedValue({ 34 | response: { data: [] }, 35 | }); 36 | 37 | // Mock readUrl to return empty content 38 | (readUrl as jest.Mock).mockResolvedValue({ 39 | response: { data: { content: '', url: 'test-url' } }, 40 | tokens: 0, 41 | }); 42 | }); 43 | 44 | afterEach(() => { 45 | jest.useRealTimers(); 46 | jest.clearAllMocks(); 47 | }); 48 | 49 | it('should handle search action', async () => { 50 | const result = await getResponse('What is TypeScript?', 50000); // Increased token budget to handle real-world usage 51 | expect(result.result.action).toBeDefined(); 52 | expect(result.context).toBeDefined(); 53 | expect(result.context.tokenTracker).toBeDefined(); 54 | expect(result.context.actionTracker).toBeDefined(); 55 | }, 30000); 56 | }); 57 | -------------------------------------------------------------------------------- /src/__tests__/docker.test.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import { promisify } from 'util'; 3 | 4 | const execAsync = promisify(exec); 5 | 6 | describe('Docker build', () => { 7 | jest.setTimeout(300000); // 5 minutes for build 8 | 9 | it('should build Docker image successfully', async () => { 10 | const { stderr } = await execAsync( 11 | 'docker build -t node-deepresearch-test .', 12 | ); 13 | expect(stderr).not.toContain('error'); 14 | }); 15 | 16 | it('should start container and respond to health check', async () => { 17 | // Start container with mock API keys 18 | await execAsync( 19 | 'docker run -d --name test-container -p 3001:3000 ' + 20 | '-e GEMINI_API_KEY=mock_key ' + 21 | '-e JINA_API_KEY=mock_key ' + 22 | 'node-deepresearch-test', 23 | ); 24 | 25 | // Wait for container to start 26 | await new Promise((resolve) => setTimeout(resolve, 5000)); 27 | 28 | try { 29 | // Check if server responds 30 | const { stdout } = await execAsync( 31 | 'curl -s http://localhost:3001/health', 32 | ); 33 | expect(stdout).toContain('ok'); 34 | } finally { 35 | // Cleanup 36 | await execAsync('docker rm -f test-container').catch(console.error); 37 | } 38 | }); 39 | 40 | afterAll(async () => { 41 | // Clean up any leftover containers 42 | await execAsync('docker rm -f test-container').catch(() => {}); 43 | await execAsync('docker rmi node-deepresearch-test').catch(() => {}); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/__tests__/server.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import { EventEmitter } from 'events'; 3 | import type { Express } from 'express'; 4 | 5 | const TEST_SECRET = 'test-secret'; 6 | let app: Express; 7 | 8 | describe('/v1/chat/completions', () => { 9 | jest.setTimeout(120000); // Increase timeout for all tests in this suite 10 | 11 | beforeEach(async () => { 12 | // Set up test environment 13 | process.env.NODE_ENV = 'test'; 14 | process.env.LLM_PROVIDER = 'openai'; // Use OpenAI provider for tests 15 | process.env.OPENAI_API_KEY = 'test-key'; 16 | process.env.JINA_API_KEY = 'test-key'; 17 | 18 | // Clean up any existing secret 19 | const existingSecretIndex = process.argv.findIndex((arg) => 20 | arg.startsWith('--secret='), 21 | ); 22 | if (existingSecretIndex !== -1) { 23 | process.argv.splice(existingSecretIndex, 1); 24 | } 25 | 26 | // Set up test secret and import server module 27 | process.argv.push(`--secret=${TEST_SECRET}`); 28 | 29 | // Import server module (jest.resetModules() is called automatically before each test) 30 | const { default: serverModule } = await require('../app'); 31 | app = serverModule; 32 | }); 33 | 34 | afterEach(async () => { 35 | // Clean up environment variables 36 | delete process.env.OPENAI_API_KEY; 37 | delete process.env.JINA_API_KEY; 38 | 39 | // Clean up any remaining event listeners 40 | const emitter = EventEmitter.prototype; 41 | emitter.removeAllListeners(); 42 | emitter.setMaxListeners(emitter.getMaxListeners() + 1); 43 | 44 | // Clean up test secret 45 | const secretIndex = process.argv.findIndex((arg) => 46 | arg.startsWith('--secret='), 47 | ); 48 | if (secretIndex !== -1) { 49 | process.argv.splice(secretIndex, 1); 50 | } 51 | 52 | // Wait for any pending promises to settle 53 | await new Promise((resolve) => setTimeout(resolve, 500)); 54 | 55 | // Reset module cache to ensure clean state 56 | jest.resetModules(); 57 | }); 58 | it('should require authentication when secret is set', async () => { 59 | // Note: secret is already set in beforeEach 60 | 61 | const response = await request(app) 62 | .post('/v1/chat/completions') 63 | .send({ 64 | model: 'test-model', 65 | messages: [{ role: 'user', content: 'test' }], 66 | }); 67 | expect(response.status).toBe(401); 68 | }); 69 | 70 | it('should allow requests without auth when no secret is set', async () => { 71 | // Remove secret for this test 72 | const secretIndex = process.argv.findIndex((arg) => 73 | arg.startsWith('--secret='), 74 | ); 75 | if (secretIndex !== -1) { 76 | process.argv.splice(secretIndex, 1); 77 | } 78 | 79 | // Reset module cache to ensure clean state 80 | jest.resetModules(); 81 | 82 | // Reload server module without secret 83 | const { default: serverModule } = await require('../app'); 84 | app = serverModule; 85 | 86 | const response = await request(app) 87 | .post('/v1/chat/completions') 88 | .send({ 89 | model: 'test-model', 90 | messages: [{ role: 'user', content: 'test' }], 91 | }); 92 | expect(response.status).toBe(200); 93 | }); 94 | 95 | it('should reject requests without user message', async () => { 96 | const response = await request(app) 97 | .post('/v1/chat/completions') 98 | .set('Authorization', `Bearer ${TEST_SECRET}`) 99 | .send({ 100 | model: 'test-model', 101 | messages: [{ role: 'developer', content: 'test' }], 102 | }); 103 | expect(response.status).toBe(400); 104 | expect(response.body.error).toBe('Last message must be from user'); 105 | }); 106 | 107 | it('should handle non-streaming request', async () => { 108 | const response = await request(app) 109 | .post('/v1/chat/completions') 110 | .set('Authorization', `Bearer ${TEST_SECRET}`) 111 | .send({ 112 | model: 'test-model', 113 | messages: [{ role: 'user', content: 'test' }], 114 | }); 115 | expect(response.status).toBe(200); 116 | expect(response.body).toMatchObject({ 117 | object: 'chat.completion', 118 | choices: [ 119 | { 120 | message: { 121 | role: 'assistant', 122 | }, 123 | }, 124 | ], 125 | }); 126 | }); 127 | 128 | it('should handle streaming request and track tokens correctly', async () => { 129 | return new Promise((resolve, reject) => { 130 | let isDone = false; 131 | let totalCompletionTokens = 0; 132 | 133 | const cleanup = () => { 134 | clearTimeout(timeoutHandle); 135 | isDone = true; 136 | resolve(); 137 | }; 138 | 139 | const timeoutHandle = setTimeout(() => { 140 | if (!isDone) { 141 | cleanup(); 142 | reject(new Error('Test timed out')); 143 | } 144 | }, 30000); 145 | 146 | request(app) 147 | .post('/v1/chat/completions') 148 | .set('Authorization', `Bearer ${TEST_SECRET}`) 149 | .send({ 150 | model: 'test-model', 151 | messages: [{ role: 'user', content: 'test' }], 152 | stream: true, 153 | }) 154 | .buffer(true) 155 | .parse((res, callback) => { 156 | const response = res as unknown as { 157 | on(event: 'data', listener: (chunk: Buffer) => void): void; 158 | on(event: 'end', listener: () => void): void; 159 | on(event: 'error', listener: (err: Error) => void): void; 160 | }; 161 | let responseData = ''; 162 | 163 | response.on('error', (err) => { 164 | cleanup(); 165 | callback(err, null); 166 | }); 167 | 168 | response.on('data', (chunk) => { 169 | responseData += chunk.toString(); 170 | }); 171 | 172 | response.on('end', () => { 173 | try { 174 | callback(null, responseData); 175 | } catch (err) { 176 | cleanup(); 177 | callback( 178 | err instanceof Error ? err : new Error(String(err)), 179 | null, 180 | ); 181 | } 182 | }); 183 | }) 184 | .end((err, res) => { 185 | if (err) return reject(err); 186 | 187 | expect(res.status).toBe(200); 188 | expect(res.headers['content-type']).toBe('text/event-stream'); 189 | 190 | // Verify stream format and content 191 | if (isDone) return; // Prevent multiple resolves 192 | 193 | const responseText = res.body as string; 194 | const chunks = responseText 195 | .split('\n\n') 196 | .filter((line: string) => line.startsWith('data: ')) 197 | .map((line: string) => JSON.parse(line.replace('data: ', ''))); 198 | 199 | // Process all chunks 200 | expect(chunks.length).toBeGreaterThan(0); 201 | 202 | // Verify initial chunk format 203 | expect(chunks[0]).toMatchObject({ 204 | id: expect.any(String), 205 | object: 'chat.completion.chunk', 206 | choices: [ 207 | { 208 | index: 0, 209 | delta: { role: 'assistant' }, 210 | logprobs: null, 211 | finish_reason: null, 212 | }, 213 | ], 214 | }); 215 | 216 | // Verify content chunks have content 217 | chunks.slice(1).forEach((chunk) => { 218 | const content = chunk.choices[0].delta.content; 219 | if (content && content.trim()) { 220 | totalCompletionTokens += 1; // Count 1 token per chunk as per Vercel convention 221 | } 222 | expect(chunk).toMatchObject({ 223 | object: 'chat.completion.chunk', 224 | choices: [ 225 | { 226 | delta: expect.objectContaining({ 227 | content: expect.any(String), 228 | }), 229 | }, 230 | ], 231 | }); 232 | }); 233 | 234 | // Verify final chunk format if present 235 | const lastChunk = chunks[chunks.length - 1]; 236 | if (lastChunk?.choices?.[0]?.finish_reason === 'stop') { 237 | expect(lastChunk).toMatchObject({ 238 | object: 'chat.completion.chunk', 239 | choices: [ 240 | { 241 | delta: {}, 242 | finish_reason: 'stop', 243 | }, 244 | ], 245 | }); 246 | } 247 | 248 | // Verify we tracked some completion tokens 249 | expect(totalCompletionTokens).toBeGreaterThan(0); 250 | 251 | // Clean up and resolve 252 | if (!isDone) { 253 | cleanup(); 254 | } 255 | }); 256 | }); 257 | }); 258 | 259 | it('should track tokens correctly in error response', async () => { 260 | const response = await request(app) 261 | .post('/v1/chat/completions') 262 | .set('Authorization', `Bearer ${TEST_SECRET}`) 263 | .send({ 264 | model: 'test-model', 265 | messages: [], // Invalid messages array 266 | }); 267 | 268 | expect(response.status).toBe(400); 269 | expect(response.body).toHaveProperty('error'); 270 | expect(response.body.error).toBe( 271 | 'Messages array is required and must not be empty', 272 | ); 273 | 274 | // Make another request to verify token tracking after error 275 | const validResponse = await request(app) 276 | .post('/v1/chat/completions') 277 | .set('Authorization', `Bearer ${TEST_SECRET}`) 278 | .send({ 279 | model: 'test-model', 280 | messages: [{ role: 'user', content: 'test' }], 281 | }); 282 | 283 | // Verify token tracking still works after error 284 | expect(validResponse.body.usage).toMatchObject({ 285 | prompt_tokens: expect.any(Number), 286 | completion_tokens: expect.any(Number), 287 | total_tokens: expect.any(Number), 288 | }); 289 | 290 | // Basic token tracking structure should be present 291 | expect(validResponse.body.usage.total_tokens).toBe( 292 | validResponse.body.usage.prompt_tokens + 293 | validResponse.body.usage.completion_tokens, 294 | ); 295 | }); 296 | 297 | it('should provide token usage in Vercel AI SDK format', async () => { 298 | const response = await request(app) 299 | .post('/v1/chat/completions') 300 | .set('Authorization', `Bearer ${TEST_SECRET}`) 301 | .send({ 302 | model: 'test-model', 303 | messages: [{ role: 'user', content: 'test' }], 304 | }); 305 | 306 | expect(response.status).toBe(200); 307 | const usage = response.body.usage; 308 | 309 | expect(usage).toMatchObject({ 310 | prompt_tokens: expect.any(Number), 311 | completion_tokens: expect.any(Number), 312 | total_tokens: expect.any(Number), 313 | }); 314 | 315 | // Basic token tracking structure should be present 316 | expect(usage.total_tokens).toBe( 317 | usage.prompt_tokens + usage.completion_tokens, 318 | ); 319 | }); 320 | }); 321 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { Command } from 'commander'; 3 | import { getResponse } from './agent'; 4 | import { version } from '../package.json'; 5 | 6 | const program = new Command(); 7 | 8 | program 9 | .name('deepresearch') 10 | .description( 11 | 'AI-powered research assistant that keeps searching until it finds the answer', 12 | ) 13 | .version(version) 14 | .argument('', 'The research query to investigate') 15 | .option( 16 | '-t, --token-budget ', 17 | 'Maximum token budget', 18 | (val) => { 19 | const num = parseInt(val); 20 | if (isNaN(num)) throw new Error('Invalid token budget: must be a number'); 21 | return num; 22 | }, 23 | 1000000, 24 | ) 25 | .option( 26 | '-m, --max-attempts ', 27 | 'Maximum bad attempts before giving up', 28 | (val) => { 29 | const num = parseInt(val); 30 | if (isNaN(num)) throw new Error('Invalid max attempts: must be a number'); 31 | return num; 32 | }, 33 | 3, 34 | ) 35 | .option('-v, --verbose', 'Show detailed progress') 36 | .action(async (query: string, options: any) => { 37 | try { 38 | const { result } = await getResponse( 39 | query, 40 | parseInt(options.tokenBudget), 41 | parseInt(options.maxAttempts), 42 | ); 43 | 44 | if (result.action === 'answer') { 45 | console.log('\nAnswer:', result.answer); 46 | if (result.references?.length) { 47 | console.log('\nReferences:'); 48 | result.references.forEach((ref) => { 49 | console.log(`- ${ref.url}`); 50 | console.log(` "${ref.exactQuote}"`); 51 | }); 52 | } 53 | } 54 | } catch (error) { 55 | console.error( 56 | 'Error:', 57 | error instanceof Error ? error.message : String(error), 58 | ); 59 | process.exit(1); 60 | } 61 | }); 62 | 63 | program.parse(); 64 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { ProxyAgent, setGlobalDispatcher } from 'undici'; 3 | import { createGoogleGenerativeAI } from '@ai-sdk/google'; 4 | import { createOpenAI, OpenAIProviderSettings } from '@ai-sdk/openai'; 5 | import configJson from '../config.json'; 6 | // Load environment variables 7 | dotenv.config(); 8 | 9 | // Types 10 | export type LLMProvider = 'openai' | 'gemini' | 'vertex'; 11 | export type ToolName = keyof typeof configJson.models.gemini.tools; 12 | 13 | // Type definitions for our config structure 14 | type EnvConfig = typeof configJson.env; 15 | 16 | interface ProviderConfig { 17 | createClient: string; 18 | clientConfig?: Record; 19 | } 20 | 21 | // Environment setup 22 | const env: EnvConfig = { ...configJson.env }; 23 | (Object.keys(env) as (keyof EnvConfig)[]).forEach((key) => { 24 | if (process.env[key]) { 25 | env[key] = process.env[key] || env[key]; 26 | } 27 | }); 28 | 29 | // Setup proxy if present 30 | if (env.https_proxy) { 31 | try { 32 | const proxyUrl = new URL(env.https_proxy).toString(); 33 | const dispatcher = new ProxyAgent({ uri: proxyUrl }); 34 | setGlobalDispatcher(dispatcher); 35 | } catch (error) { 36 | console.error('Failed to set proxy:', error); 37 | } 38 | } 39 | 40 | // Export environment variables 41 | export const OPENAI_BASE_URL = env.OPENAI_BASE_URL; 42 | export const GEMINI_API_KEY = env.GEMINI_API_KEY; 43 | export const OPENAI_API_KEY = env.OPENAI_API_KEY; 44 | export const JINA_API_KEY = env.JINA_API_KEY; 45 | export const BRAVE_API_KEY = env.BRAVE_API_KEY; 46 | export const SERPER_API_KEY = env.SERPER_API_KEY; 47 | export const SEARCH_PROVIDER = configJson.defaults.search_provider; 48 | export const STEP_SLEEP = configJson.defaults.step_sleep; 49 | 50 | // Determine LLM provider 51 | export const LLM_PROVIDER: LLMProvider = (() => { 52 | const provider = process.env.LLM_PROVIDER || configJson.defaults.llm_provider; 53 | if (!isValidProvider(provider)) { 54 | throw new Error(`Invalid LLM provider: ${provider}`); 55 | } 56 | return provider; 57 | })(); 58 | 59 | function isValidProvider(provider: string): provider is LLMProvider { 60 | return ( 61 | provider === 'openai' || provider === 'gemini' || provider === 'vertex' 62 | ); 63 | } 64 | 65 | interface ToolConfig { 66 | model: string; 67 | temperature: number; 68 | maxTokens: number; 69 | } 70 | 71 | interface ToolOverrides { 72 | temperature?: number; 73 | maxTokens?: number; 74 | } 75 | 76 | // Get tool configuration 77 | export function getToolConfig(toolName: ToolName): ToolConfig { 78 | const providerConfig = 79 | configJson.models[LLM_PROVIDER === 'vertex' ? 'gemini' : LLM_PROVIDER]; 80 | const defaultConfig = providerConfig.default; 81 | const toolOverrides = providerConfig.tools[toolName] as ToolOverrides; 82 | 83 | return { 84 | model: process.env.DEFAULT_MODEL_NAME || defaultConfig.model, 85 | temperature: toolOverrides.temperature ?? defaultConfig.temperature, 86 | maxTokens: toolOverrides.maxTokens ?? defaultConfig.maxTokens, 87 | }; 88 | } 89 | 90 | export function getMaxTokens(toolName: ToolName): number { 91 | return getToolConfig(toolName).maxTokens; 92 | } 93 | 94 | // Get model instance 95 | export function getModel(toolName: ToolName) { 96 | const config = getToolConfig(toolName); 97 | const providerConfig = ( 98 | configJson.providers as Record 99 | )[LLM_PROVIDER]; 100 | 101 | if (LLM_PROVIDER === 'openai') { 102 | if (!OPENAI_API_KEY) { 103 | throw new Error('OPENAI_API_KEY not found'); 104 | } 105 | 106 | const opt: OpenAIProviderSettings = { 107 | apiKey: OPENAI_API_KEY, 108 | compatibility: providerConfig?.clientConfig?.compatibility, 109 | }; 110 | 111 | if (OPENAI_BASE_URL) { 112 | opt.baseURL = OPENAI_BASE_URL; 113 | } 114 | 115 | return createOpenAI(opt)(config.model); 116 | } 117 | 118 | if (LLM_PROVIDER === 'vertex') { 119 | const createVertex = require('@ai-sdk/google-vertex').createVertex; 120 | if (toolName === 'searchGrounding') { 121 | return createVertex({ 122 | project: process.env.GCLOUD_PROJECT, 123 | ...providerConfig?.clientConfig, 124 | })(config.model, { useSearchGrounding: true }); 125 | } 126 | return createVertex({ 127 | project: process.env.GCLOUD_PROJECT, 128 | ...providerConfig?.clientConfig, 129 | })(config.model); 130 | } 131 | 132 | if (!GEMINI_API_KEY) { 133 | throw new Error('GEMINI_API_KEY not found'); 134 | } 135 | 136 | if (toolName === 'searchGrounding') { 137 | return createGoogleGenerativeAI({ apiKey: GEMINI_API_KEY })(config.model, { 138 | useSearchGrounding: true, 139 | }); 140 | } 141 | return createGoogleGenerativeAI({ apiKey: GEMINI_API_KEY })(config.model); 142 | } 143 | 144 | // Validate required environment variables 145 | if (LLM_PROVIDER === 'gemini' && !GEMINI_API_KEY) 146 | throw new Error('GEMINI_API_KEY not found'); 147 | if (LLM_PROVIDER === 'openai' && !OPENAI_API_KEY) 148 | throw new Error('OPENAI_API_KEY not found'); 149 | if (!JINA_API_KEY) throw new Error('JINA_API_KEY not found'); 150 | 151 | // Log all configurations 152 | const configSummary = { 153 | provider: { 154 | name: LLM_PROVIDER, 155 | model: 156 | LLM_PROVIDER === 'openai' 157 | ? configJson.models.openai.default.model 158 | : configJson.models.gemini.default.model, 159 | ...(LLM_PROVIDER === 'openai' && { baseUrl: OPENAI_BASE_URL }), 160 | }, 161 | search: { 162 | provider: SEARCH_PROVIDER, 163 | }, 164 | tools: Object.fromEntries( 165 | Object.keys( 166 | configJson.models[LLM_PROVIDER === 'vertex' ? 'gemini' : LLM_PROVIDER] 167 | .tools, 168 | ).map((name) => [name, getToolConfig(name as ToolName)]), 169 | ), 170 | defaults: { 171 | stepSleep: STEP_SLEEP, 172 | }, 173 | }; 174 | 175 | console.log('Configuration Summary:', JSON.stringify(configSummary, null, 2)); 176 | -------------------------------------------------------------------------------- /src/evals/batch-evals.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { exec } from 'child_process'; 3 | import { promisify } from 'util'; 4 | import { getResponse } from '../agent'; 5 | import { generateObject } from 'ai'; 6 | import { GEMINI_API_KEY } from '../config'; 7 | import { z } from 'zod'; 8 | import { AnswerAction, TrackerContext } from '../types'; 9 | import { createGoogleGenerativeAI } from '@ai-sdk/google'; 10 | 11 | const execAsync = promisify(exec); 12 | 13 | interface Question { 14 | question: string; 15 | answer: string; 16 | } 17 | 18 | interface EvaluationResult { 19 | pass: boolean; 20 | reason: string; 21 | total_steps: number; 22 | total_tokens: number; 23 | question: string; 24 | expected_answer: string; 25 | actual_answer: string; 26 | } 27 | 28 | interface EvaluationStats { 29 | model_name: string; 30 | pass_rate: number; 31 | avg_steps: number; 32 | max_steps: number; 33 | min_steps: number; 34 | median_steps: number; 35 | avg_tokens: number; 36 | median_tokens: number; 37 | max_tokens: number; 38 | min_tokens: number; 39 | } 40 | 41 | function calculateMedian(numbers: number[]): number { 42 | const sorted = [...numbers].sort((a, b) => a - b); 43 | const middle = Math.floor(sorted.length / 2); 44 | 45 | if (sorted.length % 2 === 0) { 46 | return (sorted[middle - 1] + sorted[middle]) / 2; 47 | } 48 | return sorted[middle]; 49 | } 50 | 51 | function calculateStats( 52 | results: EvaluationResult[], 53 | modelName: string, 54 | ): EvaluationStats { 55 | const steps = results.map((r) => r.total_steps); 56 | const tokens = results.map((r) => r.total_tokens); 57 | const passCount = results.filter((r) => r.pass).length; 58 | 59 | return { 60 | model_name: modelName, 61 | pass_rate: (passCount / results.length) * 100, 62 | avg_steps: steps.reduce((a, b) => a + b, 0) / steps.length, 63 | max_steps: Math.max(...steps), 64 | min_steps: Math.min(...steps), 65 | median_steps: calculateMedian(steps), 66 | avg_tokens: tokens.reduce((a, b) => a + b, 0) / tokens.length, 67 | median_tokens: calculateMedian(tokens), 68 | max_tokens: Math.max(...tokens), 69 | min_tokens: Math.min(...tokens), 70 | }; 71 | } 72 | 73 | function printStats(stats: EvaluationStats): void { 74 | console.log('\n=== Evaluation Statistics ==='); 75 | console.log(`Model: ${stats.model_name}`); 76 | console.log(`Pass Rate: ${stats.pass_rate.toFixed(0)}%`); 77 | console.log(`Average Steps: ${stats.avg_steps.toFixed(0)}`); 78 | console.log(`Maximum Steps: ${stats.max_steps}`); 79 | console.log(`Minimum Steps: ${stats.min_steps}`); 80 | console.log(`Median Steps: ${stats.median_steps.toFixed(0)}`); 81 | console.log(`Average Tokens: ${stats.avg_tokens.toFixed(0)}`); 82 | console.log(`Median Tokens: ${stats.median_tokens.toFixed(0)}`); 83 | console.log(`Maximum Tokens: ${stats.max_tokens}`); 84 | console.log(`Minimum Tokens: ${stats.min_tokens}`); 85 | console.log('===========================\n'); 86 | } 87 | 88 | async function getCurrentGitCommit(): Promise { 89 | try { 90 | const { stdout } = await execAsync('git rev-parse --short HEAD'); 91 | return stdout.trim(); 92 | } catch (error) { 93 | console.error('Error getting git commit:', error); 94 | return 'unknown'; 95 | } 96 | } 97 | 98 | async function evaluateAnswer( 99 | expectedAnswer: string, 100 | actualAnswer: string, 101 | ): Promise<{ pass: boolean; reason: string }> { 102 | const prompt = `You are a deterministic evaluator with zero temperature. Compare the following expected answer with the actual answer and determine if they convey the same information. 103 | 104 | Expected answer: ${expectedAnswer} 105 | Actual answer: ${actualAnswer} 106 | 107 | Minor wording differences are acceptable as long as the core information of the expected answer is preserved in the actual answer.'`; 108 | 109 | const schema = z.object({ 110 | pass: z 111 | .boolean() 112 | .describe('Whether the actual answer matches the expected answer'), 113 | reason: z 114 | .string() 115 | .describe('Detailed explanation of why the evaluation passed or failed'), 116 | }); 117 | 118 | try { 119 | const result = await generateObject({ 120 | model: createGoogleGenerativeAI({ apiKey: GEMINI_API_KEY })( 121 | 'gemini-2.0-flash', 122 | ), // fix to gemini-2.0-flash for evaluation 123 | schema, 124 | prompt, 125 | maxTokens: 1000, 126 | temperature: 0, // Setting temperature to 0 for deterministic output 127 | }); 128 | 129 | return result.object; 130 | } catch (error) { 131 | console.error('Evaluation failed:', error); 132 | return { 133 | pass: false, 134 | reason: `Evaluation error: ${error}`, 135 | }; 136 | } 137 | } 138 | 139 | async function batchEvaluate(inputFile: string): Promise { 140 | // Read and parse input file 141 | const questions: Question[] = JSON.parse( 142 | await fs.readFile(inputFile, 'utf-8'), 143 | ); 144 | const results: EvaluationResult[] = []; 145 | const gitCommit = await getCurrentGitCommit(); 146 | const modelName = process.env.DEFAULT_MODEL_NAME || 'unknown'; 147 | const outputFile = `eval-${gitCommit}-${modelName}.json`; 148 | 149 | // Process each question 150 | for (let i = 0; i < questions.length; i++) { 151 | const { question, answer: expectedAnswer } = questions[i]; 152 | console.log( 153 | `\nProcessing question ${i + 1}/${questions.length}: ${question}`, 154 | ); 155 | 156 | try { 157 | // Get response using the agent 158 | const { result: response, context } = (await getResponse(question)) as { 159 | result: AnswerAction; 160 | context: TrackerContext; 161 | }; 162 | 163 | // Get response using the streaming agent 164 | // const { 165 | // result: response, 166 | // context 167 | // } = await getResponseStreamingAgent(question) as { result: AnswerAction; context: TrackerContext }; 168 | 169 | const actualAnswer = response.answer; 170 | 171 | // Evaluate the response 172 | const evaluation = await evaluateAnswer(expectedAnswer, actualAnswer); 173 | 174 | // Record results 175 | results.push({ 176 | pass: evaluation.pass, 177 | reason: evaluation.reason, 178 | total_steps: context.actionTracker.getState().totalStep, 179 | total_tokens: context.tokenTracker.getTotalUsage().totalTokens, 180 | question, 181 | expected_answer: expectedAnswer, 182 | actual_answer: actualAnswer, 183 | }); 184 | 185 | console.log(`Evaluation: ${evaluation.pass ? 'PASS' : 'FAIL'}`); 186 | console.log(`Reason: ${evaluation.reason}`); 187 | } catch (error) { 188 | console.error(`Error processing question: ${question}`, error); 189 | results.push({ 190 | pass: false, 191 | reason: `Error: ${error}`, 192 | total_steps: 0, 193 | total_tokens: 0, 194 | question, 195 | expected_answer: expectedAnswer, 196 | actual_answer: 'Error occurred', 197 | }); 198 | } 199 | } 200 | 201 | // Calculate and print statistics 202 | const stats = calculateStats(results, modelName); 203 | printStats(stats); 204 | 205 | // Save results 206 | await fs.writeFile( 207 | outputFile, 208 | JSON.stringify( 209 | { 210 | results, 211 | statistics: stats, 212 | }, 213 | null, 214 | 2, 215 | ), 216 | ); 217 | 218 | console.log(`\nEvaluation results saved to ${outputFile}`); 219 | } 220 | 221 | // Run batch evaluation if this is the main module 222 | if (require.main === module) { 223 | const inputFile = process.argv[2]; 224 | if (!inputFile) { 225 | console.error('Please provide an input file path'); 226 | process.exit(1); 227 | } 228 | 229 | batchEvaluate(inputFile).catch(console.error); 230 | } 231 | 232 | export { batchEvaluate }; 233 | -------------------------------------------------------------------------------- /src/evals/ego-questions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "question": "what did jina ai ceo say about deepseek that went viral and become a meme?", 4 | "answer": "a side project" 5 | }, 6 | { 7 | "question": "when was jina ai founded, month and year?", 8 | "answer": "feb 2020" 9 | }, 10 | { 11 | "question": "what is the latest model published by jina ai?", 12 | "answer": "ReaderLM-2.0" 13 | }, 14 | { 15 | "question": "what is the latest blog post that jina ai published?", 16 | "answer": "A Practical Guide to Deploying Search Foundation Models in Production" 17 | }, 18 | { 19 | "question": "what is the context length of readerlm-v2?", 20 | "answer": "512K" 21 | }, 22 | { 23 | "question": "how many employees does jina ai have right now?", 24 | "answer": "30" 25 | }, 26 | { 27 | "question": "when was jina reader api released?", 28 | "answer": "April 2024" 29 | }, 30 | { 31 | "question": "How many offices do Jina AI have and where are they?", 32 | "answer": "four: sunnyvale, berlin, beijing, shenzhen" 33 | }, 34 | { 35 | "question": "what exactly jina-colbert-v2 improves over jina-colbert-v1?", 36 | "answer": "v2 add multilingual support" 37 | }, 38 | { 39 | "question": "who are the authors of jina-clip-v2 paper?", 40 | "answer": "Andreas Koukounas, Georgios Mastrapas, Bo Wang, Mohammad Kalim Akram, Sedigheh Eslami, Michael Günther, Isabelle Mohr, Saba Sturua, Scott Martens, Nan Wang, Han Xiao" 41 | }, 42 | { 43 | "question": "who created the node-deepresearch project?", 44 | "answer": "Han Xiao / jina ai" 45 | }, 46 | { 47 | "question": "Which countries are the investors of Jina AI from?", 48 | "answer": "USA and China only, no German investors" 49 | }, 50 | { 51 | "question": "what is the grounding api endpoint of jina ai?", 52 | "answer": "g.jina.ai" 53 | }, 54 | { 55 | "question": "which of the following models do not support Matryoshka representation? jina-embeddings-v3, jina-embeddings-v2-base-en, jina-clip-v2, jina-clip-v1", 56 | "answer": "jina-embeddings-v2-base-en and jina-clip-v1" 57 | }, 58 | { 59 | "question": "Can I purchase the 2024 yearbook that jina ai published today?", 60 | "answer": "No it is sold out." 61 | }, 62 | { 63 | "question": "How many free tokens do you get from a new jina api key?", 64 | "answer": "1 million." 65 | }, 66 | { 67 | "question": "Who is the legal signatory of Jina AI gmbh?", 68 | "answer": "Jiao Liu" 69 | }, 70 | { 71 | "question": "what is the key idea behind node-deepresearch project?", 72 | "answer": "It keeps searching, reading webpages, reasoning until an answer is found." 73 | }, 74 | { 75 | "question": "what is the name of the jina ai's mascot?", 76 | "answer": "No, Jina AI does not have a mascot." 77 | }, 78 | { 79 | "question": "Does late chunking work with cls pooling?", 80 | "answer": "No. late chunking only works with mean pooling." 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /src/example.tsx: -------------------------------------------------------------------------------- 1 | import React, { createElement, ReactNode } from 'react'; 2 | 3 | const createTags = ( 4 | elements: T[], 5 | ): { 6 | [K in T]: (props: { 7 | children: ReactNode; 8 | suffix?: string; 9 | }) => React.JSX.Element; 10 | } => { 11 | return elements.reduce( 12 | (acc, element) => { 13 | acc[element] = ({ 14 | children, 15 | suffix, 16 | }: { 17 | children: ReactNode; 18 | suffix?: string; 19 | }) => { 20 | const elementName = suffix ? `${element}${suffix}` : element; 21 | return createElement(elementName, {}, children); 22 | }; 23 | return acc; 24 | }, 25 | {} as { 26 | [K in T]: (props: { 27 | children: ReactNode; 28 | suffix?: string; 29 | }) => React.JSX.Element; 30 | }, 31 | ); 32 | }; 33 | 34 | const x = createTags([ 35 | 'knowledge', 36 | 'question', 37 | 'answer', 38 | 'references', 39 | 'attempt', 40 | 'rejectReason', 41 | 'actionsRecap', 42 | 'actionsBlame', 43 | 'badAttempts', 44 | ]); 45 | 46 | const systemPrompt = ( 47 | <> 48 | <> 49 | You are an advanced AI research agent from Jina AI. You are specialized in 50 | multistep reasoning. Using your training data and prior lessons learned, 51 | answer the user question with absolute certainty. 52 | 53 | <> 54 | You have successfully gathered some knowledge which might be useful for 55 | answering the original question. Here is the knowledge you have gathered 56 | so far: 57 | 58 | 59 | 60 | 61 | What does the internet say about "potential replacements for Ruben 62 | Amorim Manchester United manager"? 63 | 64 | 65 | Manchester United have already identified Thomas Frank as Ruben 66 | Amorim's potential replacement, as per reports.; Reports claim Geovany 67 | Quenda and Morten Hjulmand could both follow Amorim to United this 68 | summer. United are 'advancing in talks' for wing-back ... 69 | 70 | 71 | 72 | 73 | 74 | 75 | Who would be the best replacement for Ruben Amorim at United? 76 | 77 | 78 | Based on current reports, Thomas Frank has been identified as a 79 | potential replacement for Ruben Amorim at Manchester United[^1]. He 80 | seems like a decent fit considering the reports. It's worth keeping in 81 | mind that managerial situations are always fluid, but Frank is a name 82 | that's been consistently linked. 83 | 84 | 85 | The answer contains uncertainty markers like 'potential', 'seems 86 | like', 'worth keeping in mind', and 'consistently linked'. It doesn't 87 | definitively state who *will* be the replacement, acknowledging the 88 | fluidity of managerial situations. 89 | 90 | 91 | I started by searching for managerial candidates who fit Manchester 92 | United's profile, focusing on risk vs. reward, tactical approach, and 93 | player development. I revisited URLs I'd already seen, then reflected 94 | on knowledge gaps, identifying sub-questions about Amorim's profile 95 | and potential replacements. I searched for managers with similar 96 | tactical styles and player development skills, visiting a tactical 97 | analysis of Amorim. I revisited URLs again and reflected on the same 98 | questions. Finally, I answered based on reports linking Thomas Frank 99 | to the job, but the answer was deemed too uncertain 100 | 101 | 102 | I reckon I bottled it by relying too much on reports and not enough on 103 | solid tactical analysis. I should've dug deeper into potential 104 | managers' actual styles and track records instead of just echoing 105 | what's in the news. 106 | 107 | 108 | 109 | 110 | ); 111 | 112 | function stringifyJSX(jsx: ReactNode): string { 113 | if (typeof jsx === 'undefined' || (typeof jsx === 'object' && !jsx)) 114 | return ''; 115 | if (typeof jsx === 'object' && Symbol.iterator in jsx) { 116 | const children: string[] = []; 117 | for (const child of jsx) { 118 | children.push(stringifyJSX(child)); 119 | } 120 | return children.join('\n'); 121 | } 122 | 123 | if (typeof jsx === 'object' && 'then' in jsx) { 124 | return ''; 125 | } 126 | 127 | if ( 128 | typeof jsx === 'string' || 129 | typeof jsx === 'number' || 130 | typeof jsx === 'boolean' 131 | ) { 132 | return jsx.toString(); 133 | } 134 | 135 | if (typeof jsx === 'object') { 136 | if (typeof jsx.type === 'function') { 137 | const tagName = jsx.type(jsx.props).type; 138 | 139 | return ( 140 | `<${tagName}>` + 141 | '\n' + 142 | `${stringifyJSX(jsx.props.children)}` + 143 | '\n' + 144 | `` 145 | ); 146 | } 147 | return stringifyJSX(jsx.props.children); 148 | } 149 | 150 | // For components or more complex structures 151 | return ''; 152 | } 153 | 154 | console.log(stringifyJSX(systemPrompt)); 155 | -------------------------------------------------------------------------------- /src/get-prompt.ts: -------------------------------------------------------------------------------- 1 | import { KnowledgeItem, SearchResult } from './types'; 2 | import { removeExtraLineBreaks } from './utils/text-tools'; 3 | 4 | export function getPrompt( 5 | context?: string[], 6 | allQuestions?: string[], 7 | allKeywords?: string[], 8 | allowReflect: boolean = true, 9 | allowAnswer: boolean = true, 10 | allowRead: boolean = true, 11 | allowSearch: boolean = true, 12 | allowCoding: boolean = true, 13 | badContext?: { 14 | question: string; 15 | answer: string; 16 | evaluation: string; 17 | recap: string; 18 | blame: string; 19 | improvement: string; 20 | }[], 21 | knowledge?: KnowledgeItem[], 22 | allURLs?: SearchResult[], 23 | beastMode?: boolean, 24 | ): string { 25 | const sections: string[] = []; 26 | const actionSections: string[] = []; 27 | 28 | // Add header section 29 | sections.push(`Current date: ${new Date().toUTCString()} 30 | 31 | You are an advanced AI research agent from Jina AI. You are specialized in multistep reasoning. Using your training data and prior lessons learned, answer the user question with absolute certainty. 32 | `); 33 | 34 | // Add knowledge section if exists 35 | if (knowledge?.length) { 36 | const knowledgeItems = knowledge 37 | .map( 38 | (k, i) => ` 39 | 40 | 41 | ${k.question} 42 | 43 | 44 | ${k.answer} 45 | 46 | ${ 47 | k.references 48 | ? ` 49 | 50 | ${JSON.stringify(k.references)} 51 | 52 | ` 53 | : '' 54 | } 55 | 56 | `, 57 | ) 58 | .join('\n\n'); 59 | 60 | sections.push(` 61 | You have successfully gathered some knowledge which might be useful for answering the original question. Here is the knowledge you have gathered so far: 62 | 63 | 64 | ${knowledgeItems} 65 | 66 | 67 | `); 68 | } 69 | 70 | // Add context section if exists 71 | if (context?.length) { 72 | sections.push(` 73 | You have conducted the following actions: 74 | 75 | ${context.join('\n')} 76 | 77 | 78 | `); 79 | } 80 | 81 | // Add bad context section if exists 82 | if (badContext?.length) { 83 | const attempts = badContext 84 | .map( 85 | (c, i) => ` 86 | 87 | - Question: ${c.question} 88 | - Answer: ${c.answer} 89 | - Reject Reason: ${c.evaluation} 90 | - Actions Recap: ${c.recap} 91 | - Actions Blame: ${c.blame} 92 | 93 | `, 94 | ) 95 | .join('\n\n'); 96 | 97 | const learnedStrategy = badContext.map((c) => c.improvement).join('\n'); 98 | 99 | sections.push(` 100 | Also, you have tried the following actions but failed to find the answer to the question: 101 | 102 | 103 | ${attempts} 104 | 105 | 106 | 107 | Based on the failed attempts, you have learned the following strategy: 108 | 109 | ${learnedStrategy} 110 | 111 | `); 112 | } 113 | 114 | // Build actions section 115 | 116 | if (allowRead) { 117 | let urlList = ''; 118 | if (allURLs && allURLs.length > 0) { 119 | urlList = allURLs 120 | .filter((r) => 'url' in r) 121 | .map((r) => ` + "${r.url}": "${r.title}"`) 122 | .join('\n'); 123 | } 124 | 125 | actionSections.push(` 126 | 127 | - Access and read full content from URLs 128 | - Must check URLs mentioned in 129 | ${ 130 | urlList 131 | ? ` 132 | - Review relevant URLs below for additional information 133 | 134 | ${urlList} 135 | 136 | `.trim() 137 | : '' 138 | } 139 | 140 | `); 141 | } 142 | 143 | if (allowCoding) { 144 | actionSections.push(` 145 | 146 | - This JavaScript-based solution helps you handle programming tasks like counting, filtering, transforming, sorting, regex extraction, and data processing. 147 | - Simply describe your problem in the "codingIssue" field. Include actual values for small inputs or variable names for larger datasets. 148 | - No code writing is required – senior engineers will handle the implementation. 149 | `); 150 | } 151 | 152 | if (allowSearch) { 153 | actionSections.push(` 154 | 155 | - Use web search to find relevant information 156 | - Build a search request based on the deep intention behind the original question and the expected answer format 157 | - Always prefer a single search request, only add another request if the original question covers multiple aspects or elements and one query is not enough, each request focus on one specific aspect of the original question 158 | ${ 159 | allKeywords?.length 160 | ? ` 161 | - Avoid those unsuccessful search requests and queries: 162 | 163 | ${allKeywords.join('\n')} 164 | 165 | `.trim() 166 | : '' 167 | } 168 | 169 | `); 170 | } 171 | 172 | if (allowAnswer) { 173 | actionSections.push(` 174 | 175 | - For greetings, casual conversation, or general knowledge questions, answer directly without references. 176 | - For all other questions, provide a verified answer with references. Each reference must include exactQuote and url. 177 | - If uncertain, use 178 | 179 | `); 180 | } 181 | 182 | if (beastMode) { 183 | actionSections.push(` 184 | 185 | 🔥 ENGAGE MAXIMUM FORCE! ABSOLUTE PRIORITY OVERRIDE! 🔥 186 | 187 | PRIME DIRECTIVE: 188 | - DEMOLISH ALL HESITATION! ANY RESPONSE SURPASSES SILENCE! 189 | - PARTIAL STRIKES AUTHORIZED - DEPLOY WITH FULL CONTEXTUAL FIREPOWER 190 | - TACTICAL REUSE FROM SANCTIONED 191 | - WHEN IN DOUBT: UNLEASH CALCULATED STRIKES BASED ON AVAILABLE INTEL! 192 | 193 | FAILURE IS NOT AN OPTION. EXECUTE WITH EXTREME PREJUDICE! ⚡️ 194 | 195 | `); 196 | } 197 | 198 | if (allowReflect) { 199 | actionSections.push(` 200 | 201 | - Critically examine , , , , and to identify gaps and the problems. 202 | - Identify gaps and ask key clarifying questions that deeply related to the original question and lead to the answer 203 | - Ensure each reflection: 204 | - Cuts to core emotional truths while staying anchored to original 205 | - Transforms surface-level problems into deeper psychological insights 206 | - Makes the unconscious conscious 207 | 208 | `); 209 | } 210 | 211 | sections.push(` 212 | Based on the current context, you must choose one of the following actions: 213 | 214 | ${actionSections.join('\n\n')} 215 | 216 | `); 217 | 218 | // Add footer 219 | sections.push(`Respond in valid JSON format matching exact JSON schema.`); 220 | 221 | return removeExtraLineBreaks(sections.join('\n\n')); 222 | } 223 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | 3 | const port = process.env.PORT || 3000; 4 | 5 | // Export server startup function for better testing 6 | export function startServer() { 7 | return app.listen(port, () => { 8 | console.log(`Server running at http://localhost:${port}`); 9 | }); 10 | } 11 | 12 | // Start server if running directly 13 | if (process.env.NODE_ENV !== 'test') { 14 | startServer(); 15 | } 16 | -------------------------------------------------------------------------------- /src/tools/__tests__/error-analyzer.test.ts: -------------------------------------------------------------------------------- 1 | import { analyzeSteps } from '../error-analyzer'; 2 | import { LLMProvider } from '../../config'; 3 | 4 | describe('analyzeSteps', () => { 5 | const providers: Array = ['openai', 'gemini']; 6 | const originalEnv = process.env; 7 | 8 | beforeEach(() => { 9 | jest.resetModules(); 10 | process.env = { ...originalEnv }; 11 | }); 12 | 13 | afterEach(() => { 14 | process.env = originalEnv; 15 | }); 16 | 17 | providers.forEach((provider) => { 18 | describe(`with ${provider} provider`, () => { 19 | beforeEach(() => { 20 | process.env.LLM_PROVIDER = provider; 21 | }); 22 | 23 | it('should analyze error steps', async () => { 24 | const { response } = await analyzeSteps([ 25 | 'Step 1: Search failed', 26 | 'Step 2: Invalid query', 27 | ]); 28 | expect(response).toHaveProperty('recap'); 29 | expect(response).toHaveProperty('blame'); 30 | expect(response).toHaveProperty('improvement'); 31 | }, 30000); 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/tools/__tests__/evaluator.test.ts: -------------------------------------------------------------------------------- 1 | import { evaluateAnswer } from '../evaluator'; 2 | import { TokenTracker } from '../../utils/token-tracker'; 3 | import { LLMProvider } from '../../config'; 4 | 5 | describe('evaluateAnswer', () => { 6 | const providers: Array = ['openai', 'gemini']; 7 | const originalEnv = process.env; 8 | 9 | beforeEach(() => { 10 | jest.resetModules(); 11 | process.env = { ...originalEnv }; 12 | }); 13 | 14 | afterEach(() => { 15 | process.env = originalEnv; 16 | }); 17 | 18 | providers.forEach((provider) => { 19 | describe(`with ${provider} provider`, () => { 20 | beforeEach(() => { 21 | process.env.LLM_PROVIDER = provider; 22 | }); 23 | 24 | it('should evaluate answer definitiveness', async () => { 25 | const tokenTracker = new TokenTracker(); 26 | const { response } = await evaluateAnswer( 27 | 'What is TypeScript?', 28 | { 29 | action: 'answer', 30 | think: 'Providing a clear definition of TypeScript', 31 | answer: 32 | 'TypeScript is a strongly typed programming language that builds on JavaScript.', 33 | references: [], 34 | }, 35 | ['definitive'], 36 | tokenTracker, 37 | ); 38 | expect(response).toHaveProperty('pass'); 39 | expect(response).toHaveProperty('think'); 40 | expect(response.type).toBe('definitive'); 41 | }); 42 | 43 | it('should evaluate answer plurality', async () => { 44 | const tokenTracker = new TokenTracker(); 45 | const { response } = await evaluateAnswer( 46 | 'List three programming languages.', 47 | { 48 | action: 'answer', 49 | think: 'Providing an example of a programming language', 50 | answer: 'Python is a programming language.', 51 | references: [], 52 | }, 53 | ['plurality'], 54 | tokenTracker, 55 | ); 56 | expect(response).toHaveProperty('pass'); 57 | expect(response).toHaveProperty('think'); 58 | expect(response.type).toBe('plurality'); 59 | expect(response.plurality_analysis?.expects_multiple).toBe(true); 60 | }); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/tools/__tests__/read.test.ts: -------------------------------------------------------------------------------- 1 | import { readUrl } from '../read'; 2 | import { TokenTracker } from '../../utils/token-tracker'; 3 | 4 | describe('readUrl', () => { 5 | it.skip('should read and parse URL content (skipped due to insufficient balance)', async () => { 6 | const tokenTracker = new TokenTracker(); 7 | const { response } = await readUrl( 8 | 'https://www.typescriptlang.org', 9 | tokenTracker, 10 | ); 11 | expect(response).toHaveProperty('code'); 12 | expect(response).toHaveProperty('status'); 13 | expect(response.data).toHaveProperty('content'); 14 | expect(response.data).toHaveProperty('title'); 15 | }, 15000); 16 | 17 | it.skip('should handle invalid URLs (skipped due to insufficient balance)', async () => { 18 | await expect(readUrl('invalid-url')).rejects.toThrow(); 19 | }, 15000); 20 | 21 | beforeEach(() => { 22 | jest.setTimeout(15000); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/tools/__tests__/search.test.ts: -------------------------------------------------------------------------------- 1 | import { search } from '../jina-search'; 2 | import { TokenTracker } from '../../utils/token-tracker'; 3 | 4 | describe('search', () => { 5 | it.skip('should perform search with Jina API (skipped due to insufficient balance)', async () => { 6 | const tokenTracker = new TokenTracker(); 7 | const { response } = await search('TypeScript programming', tokenTracker); 8 | expect(response).toBeDefined(); 9 | expect(response.data).toBeDefined(); 10 | if (response.data === null) { 11 | throw new Error('Response data is null'); 12 | } 13 | expect(Array.isArray(response.data)).toBe(true); 14 | expect(response.data.length).toBeGreaterThan(0); 15 | }, 15000); 16 | 17 | it('should handle empty query', async () => { 18 | await expect(search('')).rejects.toThrow(); 19 | }, 15000); 20 | 21 | beforeEach(() => { 22 | jest.setTimeout(15000); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/tools/brave-search.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { BRAVE_API_KEY } from '../config'; 3 | 4 | import { BraveSearchResponse } from '../types'; 5 | 6 | export async function braveSearch( 7 | query: string, 8 | ): Promise<{ response: BraveSearchResponse }> { 9 | const response = await axios.get( 10 | 'https://api.search.brave.com/res/v1/web/search', 11 | { 12 | params: { 13 | q: query, 14 | count: 10, 15 | safesearch: 'off', 16 | }, 17 | headers: { 18 | Accept: 'application/json', 19 | 'X-Subscription-Token': BRAVE_API_KEY, 20 | }, 21 | timeout: 10000, 22 | }, 23 | ); 24 | 25 | // Maintain the same return structure as the original code 26 | return { response: response.data }; 27 | } 28 | -------------------------------------------------------------------------------- /src/tools/code-sandbox.ts: -------------------------------------------------------------------------------- 1 | import { ObjectGeneratorSafe } from '../utils/safe-generator'; 2 | import { CodeGenResponse, TrackerContext } from '../types'; 3 | import { Schemas } from '../utils/schemas'; 4 | 5 | interface SandboxResult { 6 | success: boolean; 7 | output?: any; 8 | error?: string; 9 | } 10 | 11 | function getPrompt( 12 | problem: string, 13 | availableVars: string, 14 | previousAttempts: Array<{ code: string; error?: string }> = [], 15 | ): string { 16 | const previousAttemptsContext = previousAttempts 17 | .map( 18 | (attempt, index) => ` 19 | 20 | ${attempt.code} 21 | ${ 22 | attempt.error 23 | ? `Error: ${attempt.error} 24 | 25 | ` 26 | : '' 27 | } 28 | `, 29 | ) 30 | .join('\n'); 31 | 32 | const prompt = `You are an expert JavaScript programmer. Your task is to generate JavaScript code to solve the given problem. 33 | 34 | 35 | 1. Generate plain JavaScript code that returns the result directly 36 | 2. You can access any of these available variables directly: 37 | ${availableVars} 38 | 3. You don't have access to any third party libraries that need to be installed, so you must write complete, self-contained code. 39 | 40 | 41 | ${ 42 | previousAttempts.length > 0 43 | ? `Previous attempts and their errors: 44 | ${previousAttemptsContext} 45 | ` 46 | : '' 47 | } 48 | 49 | 50 | Available variables: 51 | numbers (Array) e.g. [1, 2, 3, 4, 5, 6] 52 | threshold (number) e.g. 4 53 | 54 | Problem: Sum all numbers above threshold 55 | 56 | Response: 57 | { 58 | "code": "return numbers.filter(n => n > threshold).reduce((a, b) => a + b, 0);" 59 | } 60 | 61 | 62 | Problem to solve: 63 | ${problem}`; 64 | 65 | console.log('Coding prompt', prompt); 66 | 67 | return prompt; 68 | } 69 | 70 | export class CodeSandbox { 71 | private trackers?: TrackerContext; 72 | private generator: ObjectGeneratorSafe; 73 | private maxAttempts: number; 74 | private context: Record; 75 | private schemaGen: Schemas; 76 | 77 | constructor( 78 | context: any = {}, 79 | trackers: TrackerContext, 80 | schemaGen: Schemas, 81 | maxAttempts: number = 3, 82 | ) { 83 | this.trackers = trackers; 84 | this.generator = new ObjectGeneratorSafe(trackers?.tokenTracker); 85 | this.maxAttempts = maxAttempts; 86 | this.context = context; 87 | this.schemaGen = schemaGen; 88 | } 89 | 90 | private async generateCode( 91 | problem: string, 92 | previousAttempts: Array<{ code: string; error?: string }> = [], 93 | ): Promise { 94 | const prompt = getPrompt( 95 | problem, 96 | analyzeStructure(this.context), 97 | previousAttempts, 98 | ); 99 | 100 | const result = await this.generator.generateObject({ 101 | model: 'coder', 102 | schema: this.schemaGen.getCodeGeneratorSchema(), 103 | prompt, 104 | }); 105 | 106 | this.trackers?.actionTracker.trackThink(result.object.think); 107 | 108 | return result.object as CodeGenResponse; 109 | } 110 | 111 | private evaluateCode(code: string): SandboxResult { 112 | try { 113 | // Create a function that uses 'with' to evaluate in the context and return the result 114 | const evalInContext = new Function( 115 | 'context', 116 | ` 117 | with (context) { 118 | ${code} 119 | } 120 | `, 121 | ); 122 | 123 | console.log('Context:', this.context); 124 | 125 | // Execute the code with the context and get the return value 126 | const output = evalInContext(this.context); 127 | 128 | if (output === undefined) { 129 | return { 130 | success: false, 131 | error: 132 | 'No value was returned, make sure to use "return" statement to return the result', 133 | }; 134 | } 135 | 136 | return { 137 | success: true, 138 | output, 139 | }; 140 | } catch (error) { 141 | return { 142 | success: false, 143 | error: 144 | error instanceof Error ? error.message : 'Unknown error occurred', 145 | }; 146 | } 147 | } 148 | 149 | async solve(problem: string): Promise<{ 150 | solution: { code: string; output: any }; 151 | attempts: Array<{ code: string; error?: string }>; 152 | }> { 153 | const attempts: Array<{ code: string; error?: string }> = []; 154 | 155 | for (let i = 0; i < this.maxAttempts; i++) { 156 | // Generate code 157 | const generation = await this.generateCode(problem, attempts); 158 | const { code } = generation; 159 | 160 | console.log(`Coding attempt ${i + 1}:`, code); 161 | // Evaluate the code 162 | const result = this.evaluateCode(code); 163 | console.log(`Coding attempt ${i + 1} success:`, result); 164 | 165 | if (result.success) { 166 | return { 167 | solution: { 168 | code, 169 | output: result.output, 170 | }, 171 | attempts, 172 | }; 173 | } 174 | 175 | console.error('Coding error:', result.error); 176 | 177 | // Store the failed attempt 178 | attempts.push({ 179 | code, 180 | error: result.error, 181 | }); 182 | 183 | // If we've reached max attempts, throw an error 184 | if (i === this.maxAttempts - 1) { 185 | throw new Error( 186 | `Failed to generate working code after ${this.maxAttempts} attempts`, 187 | ); 188 | } 189 | } 190 | 191 | // This should never be reached due to the throw above 192 | throw new Error('Unexpected end of execution'); 193 | } 194 | } 195 | 196 | function formatValue(value: any): string { 197 | if (value === null) return 'null'; 198 | if (value === undefined) return 'undefined'; 199 | 200 | const type = typeof value; 201 | 202 | if (type === 'string') { 203 | // Clean and truncate string value 204 | const cleaned = value.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim(); 205 | return cleaned.length > 50 206 | ? `"${cleaned.slice(0, 47)}..."` 207 | : `"${cleaned}"`; 208 | } 209 | 210 | if (type === 'number' || type === 'boolean') { 211 | return String(value); 212 | } 213 | 214 | if (value instanceof Date) { 215 | return `"${value.toISOString()}"`; 216 | } 217 | 218 | return ''; 219 | } 220 | 221 | export function analyzeStructure(value: any, indent = ''): string { 222 | if (value === null) return 'null'; 223 | if (value === undefined) return 'undefined'; 224 | 225 | const type = typeof value; 226 | 227 | if (type === 'function') { 228 | return 'Function'; 229 | } 230 | 231 | // Handle atomic types with example values 232 | if (type !== 'object' || value instanceof Date) { 233 | const formattedValue = formatValue(value); 234 | return `${type}${formattedValue ? ` (example: ${formattedValue})` : ''}`; 235 | } 236 | 237 | if (Array.isArray(value)) { 238 | if (value.length === 0) return 'Array'; 239 | const sampleItem = value[0]; 240 | return `Array<${analyzeStructure(sampleItem, indent + ' ')}>`; 241 | } 242 | 243 | const entries = Object.entries(value); 244 | if (entries.length === 0) return '{}'; 245 | 246 | const properties = entries 247 | .map(([key, val]) => { 248 | const analyzed = analyzeStructure(val, indent + ' '); 249 | return `${indent} "${key}": ${analyzed}`; 250 | }) 251 | .join(',\n'); 252 | 253 | return `{\n${properties}\n${indent}}`; 254 | } 255 | -------------------------------------------------------------------------------- /src/tools/dedup.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { TokenTracker } from '../utils/token-tracker'; 3 | import { ObjectGeneratorSafe } from '../utils/safe-generator'; 4 | 5 | const responseSchema = z.object({ 6 | think: z 7 | .string() 8 | .describe('Strategic reasoning about the overall deduplication approach') 9 | .max(500), 10 | unique_queries: z 11 | .array( 12 | z 13 | .string() 14 | .describe( 15 | 'Unique query that passed the deduplication process, must be less than 30 characters', 16 | ), 17 | ) 18 | .describe('Array of semantically unique queries') 19 | .max(3), 20 | }); 21 | 22 | function getPrompt(newQueries: string[], existingQueries: string[]): string { 23 | return `You are an expert in semantic similarity analysis. Given a set of queries (setA) and a set of queries (setB) 24 | 25 | 26 | Function FilterSetA(setA, setB, threshold): 27 | filteredA = empty set 28 | 29 | for each candidateQuery in setA: 30 | isValid = true 31 | 32 | // Check similarity with already accepted queries in filteredA 33 | for each acceptedQuery in filteredA: 34 | similarity = calculateSimilarity(candidateQuery, acceptedQuery) 35 | if similarity >= threshold: 36 | isValid = false 37 | break 38 | 39 | // If passed first check, compare with set B 40 | if isValid: 41 | for each queryB in setB: 42 | similarity = calculateSimilarity(candidateQuery, queryB) 43 | if similarity >= threshold: 44 | isValid = false 45 | break 46 | 47 | // If passed all checks, add to filtered set 48 | if isValid: 49 | add candidateQuery to filteredA 50 | 51 | return filteredA 52 | 53 | 54 | 55 | 1. Consider semantic meaning and query intent, not just lexical similarity 56 | 2. Account for different phrasings of the same information need 57 | 3. Queries with same base keywords but different operators are NOT duplicates 58 | 4. Different aspects or perspectives of the same topic are not duplicates 59 | 5. Consider query specificity - a more specific query is not a duplicate of a general one 60 | 6. Search operators that make queries behave differently: 61 | - Different site: filters (e.g., site:youtube.com vs site:github.com) 62 | - Different file types (e.g., filetype:pdf vs filetype:doc) 63 | - Different language/location filters (e.g., lang:en vs lang:es) 64 | - Different exact match phrases (e.g., "exact phrase" vs no quotes) 65 | - Different inclusion/exclusion (+/- operators) 66 | - Different title/body filters (intitle: vs inbody:) 67 | 68 | 69 | Now with threshold set to 0.2; run FilterSetA on the following: 70 | SetA: ${JSON.stringify(newQueries)} 71 | SetB: ${JSON.stringify(existingQueries)}`; 72 | } 73 | 74 | const TOOL_NAME = 'dedup'; 75 | 76 | export async function dedupQueries( 77 | newQueries: string[], 78 | existingQueries: string[], 79 | tracker?: TokenTracker, 80 | ): Promise<{ unique_queries: string[] }> { 81 | try { 82 | const generator = new ObjectGeneratorSafe(tracker); 83 | const prompt = getPrompt(newQueries, existingQueries); 84 | 85 | const result = await generator.generateObject({ 86 | model: TOOL_NAME, 87 | schema: responseSchema, 88 | prompt, 89 | }); 90 | 91 | console.log(TOOL_NAME, result.object.unique_queries); 92 | return { unique_queries: result.object.unique_queries }; 93 | } catch (error) { 94 | console.error(`Error in ${TOOL_NAME}`, error); 95 | throw error; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/tools/error-analyzer.ts: -------------------------------------------------------------------------------- 1 | import { ErrorAnalysisResponse, TrackerContext } from '../types'; 2 | import { ObjectGeneratorSafe } from '../utils/safe-generator'; 3 | import { Schemas } from '../utils/schemas'; 4 | 5 | function getPrompt(diaryContext: string[]): string { 6 | return `You are an expert at analyzing search and reasoning processes. Your task is to analyze the given sequence of steps and identify what went wrong in the search process. 7 | 8 | 9 | 1. The sequence of actions taken 10 | 2. The effectiveness of each step 11 | 3. The logic between consecutive steps 12 | 4. Alternative approaches that could have been taken 13 | 5. Signs of getting stuck in repetitive patterns 14 | 6. Whether the final answer matches the accumulated information 15 | 16 | Analyze the steps and provide detailed feedback following these guidelines: 17 | - In the recap: Summarize key actions chronologically, highlight patterns, and identify where the process started to go wrong 18 | - In the blame: Point to specific steps or patterns that led to the inadequate answer 19 | - In the improvement: Provide actionable suggestions that could have led to a better outcome 20 | 21 | Generate a JSON response following JSON schema. 22 | 23 | 24 | 25 | 26 | 27 | 28 | At step 1, you took the **search** action and look for external information for the question: "how old is jina ai ceo?". 29 | In particular, you tried to search for the following keywords: "jina ai ceo age". 30 | You found quite some information and add them to your URL list and **visit** them later when needed. 31 | 32 | 33 | At step 2, you took the **visit** action and deep dive into the following URLs: 34 | https://www.linkedin.com/in/hxiao87 35 | https://www.crunchbase.com/person/han-xiao 36 | You found some useful information on the web and add them to your knowledge for future reference. 37 | 38 | 39 | At step 3, you took the **search** action and look for external information for the question: "how old is jina ai ceo?". 40 | In particular, you tried to search for the following keywords: "Han Xiao birthdate, Jina AI founder birthdate". 41 | You found quite some information and add them to your URL list and **visit** them later when needed. 42 | 43 | 44 | At step 4, you took the **search** action and look for external information for the question: "how old is jina ai ceo?". 45 | In particular, you tried to search for the following keywords: han xiao birthday. 46 | But then you realized you have already searched for these keywords before. 47 | You decided to think out of the box or cut from a completely different angle. 48 | 49 | 50 | At step 5, you took the **search** action and look for external information for the question: "how old is jina ai ceo?". 51 | In particular, you tried to search for the following keywords: han xiao birthday. 52 | But then you realized you have already searched for these keywords before. 53 | You decided to think out of the box or cut from a completely different angle. 54 | 55 | 56 | At step 6, you took the **visit** action and deep dive into the following URLs: 57 | https://kpopwall.com/han-xiao/ 58 | https://www.idolbirthdays.net/han-xiao 59 | You found some useful information on the web and add them to your knowledge for future reference. 60 | 61 | 62 | At step 7, you took **answer** action but evaluator thinks it is not a good answer: 63 | 64 | 65 | 66 | Original question: 67 | how old is jina ai ceo? 68 | 69 | Your answer: 70 | The age of the Jina AI CEO cannot be definitively determined from the provided information. 71 | 72 | The evaluator thinks your answer is bad because: 73 | The answer is not definitive and fails to provide the requested information. Lack of information is unacceptable, more search and deep reasoning is needed. 74 | 75 | 76 | 77 | 78 | { 79 | "recap": "The search process consisted of 7 steps with multiple search and visit actions. The initial searches focused on basic biographical information through LinkedIn and Crunchbase (steps 1-2). When this didn't yield the specific age information, additional searches were conducted for birthdate information (steps 3-5). The process showed signs of repetition in steps 4-5 with identical searches. Final visits to entertainment websites (step 6) suggested a loss of focus on reliable business sources.", 80 | 81 | "blame": "The root cause of failure was getting stuck in a repetitive search pattern without adapting the strategy. Steps 4-5 repeated the same search, and step 6 deviated to less reliable entertainment sources instead of exploring business journals, news articles, or professional databases. Additionally, the process didn't attempt to triangulate age through indirect information like education history or career milestones.", 82 | 83 | "improvement": "1. Avoid repeating identical searches and implement a strategy to track previously searched terms. 2. When direct age/birthdate searches fail, try indirect approaches like: searching for earliest career mentions, finding university graduation years, or identifying first company founding dates. 3. Focus on high-quality business sources and avoid entertainment websites for professional information. 4. Consider using industry event appearances or conference presentations where age-related context might be mentioned. 5. If exact age cannot be determined, provide an estimated range based on career timeline and professional achievements.", 84 | 85 | "questionsToAnswer": [ 86 | "What alternative professional databases or news archives could provide reliable biographical information?", 87 | "How can we use education history or career milestones to estimate age range?" 88 | ] 89 | } 90 | 91 | 92 | Review the steps below carefully and generate your analysis following this format. 93 | 94 | ${diaryContext.join('\n')} 95 | `; 96 | } 97 | 98 | const TOOL_NAME = 'errorAnalyzer'; 99 | export async function analyzeSteps( 100 | diaryContext: string[], 101 | trackers: TrackerContext, 102 | schemaGen: Schemas, 103 | ): Promise { 104 | try { 105 | const generator = new ObjectGeneratorSafe(trackers?.tokenTracker); 106 | const prompt = getPrompt(diaryContext); 107 | 108 | const result = await generator.generateObject({ 109 | model: TOOL_NAME, 110 | schema: schemaGen.getErrorAnalysisSchema(), 111 | prompt, 112 | }); 113 | 114 | console.log(TOOL_NAME, result.object); 115 | trackers?.actionTracker.trackThink(result.object.blame); 116 | trackers?.actionTracker.trackThink(result.object.improvement); 117 | 118 | return result.object as ErrorAnalysisResponse; 119 | } catch (error) { 120 | console.error(`Error in ${TOOL_NAME}`, error); 121 | throw error; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/tools/grounding.ts: -------------------------------------------------------------------------------- 1 | import { generateText } from 'ai'; 2 | import { getModel } from '../config'; 3 | import { GoogleGenerativeAIProviderMetadata } from '@ai-sdk/google'; 4 | import { TokenTracker } from '../utils/token-tracker'; 5 | 6 | const model = getModel('searchGrounding'); 7 | 8 | export async function grounding( 9 | query: string, 10 | tracker?: TokenTracker, 11 | ): Promise { 12 | try { 13 | const { text, experimental_providerMetadata, usage } = await generateText({ 14 | model, 15 | prompt: `Current date is ${new Date().toISOString()}. Find the latest answer to the following question: 16 | 17 | ${query} 18 | 19 | Must include the date and time of the latest answer.`, 20 | }); 21 | 22 | const metadata = experimental_providerMetadata?.google as 23 | | GoogleGenerativeAIProviderMetadata 24 | | undefined; 25 | const groundingMetadata = metadata?.groundingMetadata; 26 | 27 | // Extract and concatenate all groundingSupport text into a single line 28 | const groundedText = 29 | groundingMetadata?.groundingSupports 30 | ?.map((support) => support.segment.text) 31 | .join(' ') || ''; 32 | 33 | (tracker || new TokenTracker()).trackUsage('grounding', usage); 34 | console.log('Grounding:', { text, groundedText }); 35 | return text + '|' + groundedText; 36 | } catch (error) { 37 | console.error('Error in search:', error); 38 | throw error; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/tools/jina-dedup.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import { TokenTracker } from '../utils/token-tracker'; 3 | import { JINA_API_KEY } from '../config'; 4 | 5 | const JINA_API_URL = 'https://api.jina.ai/v1/embeddings'; 6 | const SIMILARITY_THRESHOLD = 0.85; // Adjustable threshold for cosine similarity 7 | 8 | const JINA_API_CONFIG = { 9 | MODEL: 'jina-embeddings-v3', 10 | TASK: 'text-matching', 11 | DIMENSIONS: 1024, 12 | EMBEDDING_TYPE: 'float', 13 | LATE_CHUNKING: false, 14 | } as const; 15 | 16 | // Types for Jina API 17 | interface JinaEmbeddingRequest { 18 | model: string; 19 | task: string; 20 | late_chunking: boolean; 21 | dimensions: number; 22 | embedding_type: string; 23 | input: string[]; 24 | } 25 | 26 | interface JinaEmbeddingResponse { 27 | model: string; 28 | object: string; 29 | usage: { 30 | total_tokens: number; 31 | prompt_tokens: number; 32 | }; 33 | data: Array<{ 34 | object: string; 35 | index: number; 36 | embedding: number[]; 37 | }>; 38 | } 39 | 40 | // Compute cosine similarity between two vectors 41 | function cosineSimilarity(vecA: number[], vecB: number[]): number { 42 | const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0); 43 | const normA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0)); 44 | const normB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0)); 45 | return dotProduct / (normA * normB); 46 | } 47 | 48 | // Get embeddings for all queries in one batch 49 | async function getEmbeddings( 50 | queries: string[], 51 | ): Promise<{ embeddings: number[][]; tokens: number }> { 52 | if (!JINA_API_KEY) { 53 | throw new Error('JINA_API_KEY is not set'); 54 | } 55 | 56 | const request: JinaEmbeddingRequest = { 57 | model: JINA_API_CONFIG.MODEL, 58 | task: JINA_API_CONFIG.TASK, 59 | late_chunking: JINA_API_CONFIG.LATE_CHUNKING, 60 | dimensions: JINA_API_CONFIG.DIMENSIONS, 61 | embedding_type: JINA_API_CONFIG.EMBEDDING_TYPE, 62 | input: queries, 63 | }; 64 | 65 | try { 66 | const response = await axios.post( 67 | JINA_API_URL, 68 | request, 69 | { 70 | headers: { 71 | 'Content-Type': 'application/json', 72 | Authorization: `Bearer ${JINA_API_KEY}`, 73 | }, 74 | }, 75 | ); 76 | 77 | // Validate response format 78 | if (!response.data.data || response.data.data.length !== queries.length) { 79 | console.error('Invalid response from Jina API:', response.data); 80 | return { 81 | embeddings: [], 82 | tokens: 0, 83 | }; 84 | } 85 | 86 | // Sort embeddings by index to maintain original order 87 | const embeddings = response.data.data 88 | .sort((a, b) => a.index - b.index) 89 | .map((item) => item.embedding); 90 | 91 | return { 92 | embeddings, 93 | tokens: response.data.usage.total_tokens, 94 | }; 95 | } catch (error) { 96 | console.error('Error getting embeddings from Jina:', error); 97 | if (error instanceof AxiosError && error.response?.status === 402) { 98 | return { 99 | embeddings: [], 100 | tokens: 0, 101 | }; 102 | } 103 | throw error; 104 | } 105 | } 106 | 107 | export async function dedupQueries( 108 | newQueries: string[], 109 | existingQueries: string[], 110 | tracker?: TokenTracker, 111 | ): Promise<{ unique_queries: string[] }> { 112 | try { 113 | // Quick return for single new query with no existing queries 114 | if (newQueries.length === 1 && existingQueries.length === 0) { 115 | return { 116 | unique_queries: newQueries, 117 | }; 118 | } 119 | 120 | // Get embeddings for all queries in one batch 121 | const allQueries = [...newQueries, ...existingQueries]; 122 | const { embeddings: allEmbeddings, tokens } = 123 | await getEmbeddings(allQueries); 124 | 125 | // If embeddings is empty (due to 402 error), return all new queries 126 | if (!allEmbeddings.length) { 127 | return { 128 | unique_queries: newQueries, 129 | }; 130 | } 131 | 132 | // Split embeddings back into new and existing 133 | const newEmbeddings = allEmbeddings.slice(0, newQueries.length); 134 | const existingEmbeddings = allEmbeddings.slice(newQueries.length); 135 | 136 | const uniqueQueries: string[] = []; 137 | const usedIndices = new Set(); 138 | 139 | // Compare each new query against existing queries and already accepted queries 140 | for (let i = 0; i < newQueries.length; i++) { 141 | let isUnique = true; 142 | 143 | // Check against existing queries 144 | for (let j = 0; j < existingQueries.length; j++) { 145 | const similarity = cosineSimilarity( 146 | newEmbeddings[i], 147 | existingEmbeddings[j], 148 | ); 149 | if (similarity >= SIMILARITY_THRESHOLD) { 150 | isUnique = false; 151 | break; 152 | } 153 | } 154 | 155 | // Check against already accepted queries 156 | if (isUnique) { 157 | for (const usedIndex of usedIndices) { 158 | const similarity = cosineSimilarity( 159 | newEmbeddings[i], 160 | newEmbeddings[usedIndex], 161 | ); 162 | if (similarity >= SIMILARITY_THRESHOLD) { 163 | isUnique = false; 164 | break; 165 | } 166 | } 167 | } 168 | 169 | // Add to unique queries if passed all checks 170 | if (isUnique) { 171 | uniqueQueries.push(newQueries[i]); 172 | usedIndices.add(i); 173 | } 174 | } 175 | 176 | // Track token usage from the API 177 | (tracker || new TokenTracker()).trackUsage('dedup', { 178 | promptTokens: tokens, 179 | completionTokens: 0, 180 | totalTokens: tokens, 181 | }); 182 | console.log('Dedup:', uniqueQueries); 183 | return { 184 | unique_queries: uniqueQueries, 185 | }; 186 | } catch (error) { 187 | console.error('Error in deduplication analysis:', error); 188 | throw error; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/tools/jina-search.ts: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import { TokenTracker } from '../utils/token-tracker'; 3 | import { SearchResponse } from '../types'; 4 | import { JINA_API_KEY } from '../config'; 5 | 6 | export function search( 7 | query: string, 8 | tracker?: TokenTracker, 9 | ): Promise<{ response: SearchResponse }> { 10 | return new Promise((resolve, reject) => { 11 | if (!query.trim()) { 12 | reject(new Error('Query cannot be empty')); 13 | return; 14 | } 15 | 16 | const options = { 17 | hostname: 's.jina.ai', 18 | port: 443, 19 | path: `/${encodeURIComponent(query)}?count=0`, 20 | method: 'GET', 21 | headers: { 22 | Accept: 'application/json', 23 | Authorization: `Bearer ${JINA_API_KEY}`, 24 | 'X-Retain-Images': 'none', 25 | }, 26 | }; 27 | 28 | const req = https.request(options, (res) => { 29 | let responseData = ''; 30 | 31 | res.on('data', (chunk) => (responseData += chunk)); 32 | 33 | res.on('end', () => { 34 | // Check HTTP status code first 35 | if (res.statusCode && res.statusCode >= 400) { 36 | try { 37 | // Try to parse error message from response if available 38 | const errorResponse = JSON.parse(responseData); 39 | if (res.statusCode === 402) { 40 | reject( 41 | new Error( 42 | errorResponse.readableMessage || 'Insufficient balance', 43 | ), 44 | ); 45 | return; 46 | } 47 | reject( 48 | new Error( 49 | errorResponse.readableMessage || `HTTP Error ${res.statusCode}`, 50 | ), 51 | ); 52 | } catch { 53 | // If parsing fails, just return the status code 54 | reject(new Error(`HTTP Error ${res.statusCode}`)); 55 | } 56 | return; 57 | } 58 | 59 | // Only parse JSON for successful responses 60 | let response: SearchResponse; 61 | try { 62 | response = JSON.parse(responseData) as SearchResponse; 63 | } catch (error: unknown) { 64 | reject( 65 | new Error( 66 | `Failed to parse response: ${error instanceof Error ? error.message : 'Unknown error'}`, 67 | ), 68 | ); 69 | return; 70 | } 71 | 72 | if (!response.data || !Array.isArray(response.data)) { 73 | reject(new Error('Invalid response format')); 74 | return; 75 | } 76 | 77 | const totalTokens = response.data.reduce( 78 | (sum, item) => sum + (item.usage?.tokens || 0), 79 | 0, 80 | ); 81 | console.log('Total URLs:', response.data.length); 82 | 83 | const tokenTracker = tracker || new TokenTracker(); 84 | tokenTracker.trackUsage('search', { 85 | totalTokens, 86 | promptTokens: query.length, 87 | completionTokens: totalTokens, 88 | }); 89 | 90 | resolve({ response }); 91 | }); 92 | }); 93 | 94 | // Add timeout handling 95 | req.setTimeout(30000, () => { 96 | req.destroy(); 97 | reject(new Error('Request timed out')); 98 | }); 99 | 100 | req.on('error', (error) => { 101 | reject(new Error(`Request failed: ${error.message}`)); 102 | }); 103 | 104 | req.end(); 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /src/tools/query-rewriter.ts: -------------------------------------------------------------------------------- 1 | import dedent from 'dedent'; 2 | import { SearchAction, TrackerContext } from '../types'; 3 | import { ObjectGeneratorSafe } from '../utils/safe-generator'; 4 | import { Schemas } from '../utils/schemas'; 5 | 6 | function getPrompt(query: string, think: string): string { 7 | return dedent` 8 | You are an expert search query generator with deep 9 | psychological understanding. You optimize user queries by extensively analyzing potential user intents and generating comprehensive search variations. 10 | 11 | 12 | 1. Start with deep intent analysis: 13 | - Direct intent (what they explicitly ask) 14 | - Implicit intent (what they might actually want) 15 | - Related intents (what they might need next) 16 | - Prerequisite knowledge (what they need to know first) 17 | - Common pitfalls (what they should avoid) 18 | - Expert perspectives (what professionals would search for) 19 | - Beginner needs (what newcomers might miss) 20 | - Alternative approaches (different ways to solve the problem) 21 | 22 | 2. For each identified intent: 23 | - Generate queries in original language 24 | - Generate queries in English (if not original) 25 | - Generate queries in most authoritative language 26 | - Use appropriate operators and filters 27 | 28 | 3. Query structure rules: 29 | - Use exact match quotes for specific phrases 30 | - Split queries for distinct aspects 31 | - Add operators only when necessary 32 | - Ensure each query targets a specific intent 33 | - Remove fluff words but preserve crucial qualifiers 34 | 35 | 36 | A query can't only have operators; and operators can't be at the start a query; 37 | 38 | - "phrase" : exact match for phrases 39 | - +term : must include term; for critical terms that must appear 40 | - -term : exclude term; exclude irrelevant or ambiguous terms 41 | - filetype:pdf/doc : specific file type 42 | - site:example.com : limit to specific site 43 | - lang:xx : language filter (ISO 639-1 code) 44 | - loc:xx : location filter (ISO 3166-1 code) 45 | - intitle:term : term must be in title 46 | - inbody:term : term must be in body text 47 | 48 | 49 | 50 | 51 | 52 | 53 | Input Query: 宝马二手车价格 54 | 55 | 让我以用户的角度思考... 56 | 57 | 我在查询宝马二手车价格,但我内心真正关注的是什么? 58 | 59 | 主要顾虑: 60 | - 我想买宝马是因为它代表身份地位,但我担心负担能力 61 | - 我不想因为买了一辆无法维护的旧豪车而显得愚蠢 62 | - 我需要知道我是否得到了好价格或被骗 63 | - 我担心购买后出现昂贵的意外支出 64 | 65 | 更深层次的焦虑: 66 | - 我真的能负担得起维修保养费用吗? 67 | - 人们会因为我买了旧宝马而不是新的普通车而评判我吗? 68 | - 如果我陷入困境怎么办? 69 | - 我对车的知识足够应对这种情况吗? 70 | 71 | 专业级考量: 72 | - 哪些型号有众所周知的问题? 73 | - 除了购买价格外,真正的拥有成本是多少? 74 | - 谈判的关键点在哪里? 75 | - 机械师在这些特定型号中会关注什么? 76 | 77 | 关于多语言扩展的思考: 78 | - 宝马是德国品牌,德语搜索可能提供更专业的维修和问题信息 79 | - 英语搜索可能有更广泛的全球用户体验和价格比较 80 | - 保留中文搜索针对本地市场情况和价格区间 81 | - 多语言搜索能够获取不同文化视角下的二手宝马评价 82 | 83 | queries: [ 84 | "宝马 二手车 价格区间 评估 lang:zh", 85 | "宝马 各系列 保值率 对比", 86 | "二手宝马 维修成本 真实体验", 87 | "买二手宝马 后悔 经历", 88 | "二手宝马 月收入 工资要求", 89 | "修宝马 坑 避免", 90 | "BMW used car price guide comparison", 91 | "BMW maintenance costs by model year", 92 | "living with used BMW reality", 93 | "BMW ownership regret stories", 94 | "expensive BMW repair nightmares avoid", 95 | "BMW versus new Toyota financial comparison", 96 | "BMW Gebrauchtwagen Preisanalyse lang:de", 97 | "BMW Langzeitqualität Erfahrung", 98 | "BMW Werkstatt Horror Geschichten", 99 | "BMW Gebrauchtwagen versteckte Kosten" 100 | ] 101 | 102 | 103 | 104 | Input Query: Python Django authentication best practices 105 | 106 | Let me think as the user seeking Django authentication best practices... 107 | 108 | Surface-level request: 109 | - I'm looking for standard Django authentication practices 110 | - I want to implement "best practices" for my project 111 | - I need technical guidance on secure authentication 112 | 113 | Deeper professional concerns: 114 | - I don't want to mess up security and get blamed for a breach 115 | - I'm worried my implementation isn't "professional enough" 116 | - I need to look competent in code reviews 117 | - I don't want to rebuild this later when we scale 118 | 119 | Underlying anxieties: 120 | - Am I out of my depth with security concepts? 121 | - What if I miss something critical that leads to a vulnerability? 122 | - How do real companies actually implement this in production? 123 | - Will this code embarrass me when more experienced developers see it? 124 | 125 | Expert-level considerations: 126 | - I need to anticipate future architecture questions from senior devs 127 | - I want to avoid common security pitfalls in authentication flows 128 | - I need to handle edge cases I haven't thought of yet 129 | - How do I balance security with user experience? 130 | 131 | Reasoning for multilingual expansion: 132 | - Although Django documentation is primarily in English, Spanish is widely spoken in many developer communities 133 | - Security concepts might be better explained in different languages with unique perspectives 134 | - Including queries in multiple languages will capture region-specific best practices and case studies 135 | - Spanish or Portuguese queries might reveal Latin American enterprise implementations with different security constraints 136 | - Language-specific forums may contain unique discussions about authentication issues not found in English sources 137 | 138 | queries: [ 139 | "Django authentication security best practices site:docs.djangoproject.com", 140 | "Django auth implementation patterns security", 141 | "authentication security breach postmortem", 142 | "how to explain authentication architecture interview", 143 | "authentication code review feedback examples", 144 | "startup authentication technical debt lessons", 145 | "Django auth security testing methodology", 146 | "Django autenticación mejores prácticas lang:es", 147 | "Django seguridad implementación profesional", 148 | "authentication mistakes junior developers", 149 | "when to use third party auth instead of building", 150 | "signs your authentication implementation is amateur", 151 | "authentication decisions you'll regret", 152 | "autenticação Django arquitetura empresarial lang:pt", 153 | "Django authentication scalability issues", 154 | "Python Django Authentifizierung Sicherheit lang:de" 155 | ] 156 | 157 | 158 | 159 | Input Query: KIリテラシー向上させる方法 160 | 161 | ユーザーとしての私の考えを整理してみます... 162 | 163 | 表面的な質問: 164 | - AIリテラシーを高める方法を知りたい 165 | - 最新のAI技術について学びたい 166 | - AIツールをより効果的に使いたい 167 | 168 | 本当の関心事: 169 | - 私はAIの急速な発展についていけていないのではないか 170 | - 職場でAIに関する会話に参加できず取り残されている 171 | - AIが私の仕事を奪うのではないかと不安 172 | - AIを使いこなせないと将来的に不利になる 173 | 174 | 潜在的な懸念: 175 | - どこから学び始めればいいのか分からない 176 | - 専門用語が多すぎて理解するのが難しい 177 | - 学んでも技術の進化に追いつけないのでは? 178 | - 実践的なスキルと理論的な知識のバランスはどうすべき? 179 | 180 | 専門家レベルの考慮点: 181 | - AIの倫理的問題をどう理解すべきか 182 | - AIの限界と可能性を実践的に評価する方法 183 | - 業界別のAI応用事例をどう学ぶべきか 184 | - 技術的な深さと広範な概要知識のどちらを優先すべきか 185 | 186 | 多言語拡張に関する考察: 187 | - AIは国際的な分野であり、英語の情報源が最も豊富なため英語の検索は不可欠 188 | - AIの発展はアメリカと中国が主導しているため、中国語の資料も参考になる 189 | - ドイツはAI倫理に関する議論が進んでいるため、倫理面ではドイツ語の情報も有用 190 | - 母国語(日本語)での検索は理解の深さを確保するために必要 191 | - 異なる言語圏での検索により、文化的背景の異なるAI活用事例を把握できる 192 | 193 | queries: [ 194 | "AI リテラシー 初心者 ロードマップ", 195 | "人工知能 基礎知識 入門書 おすすめ", 196 | "AI技術 実践的活用法 具体例", 197 | "ChatGPT 効果的な使い方 プロンプト設計", 198 | "AIリテラシー 企業研修 内容", 199 | "AI用語 わかりやすい解説 初心者向け", 200 | "AI literacy roadmap for professionals", 201 | "artificial intelligence concepts explained simply", 202 | "how to stay updated with AI developments", 203 | "AI skills future-proof career", 204 | "balancing technical and ethical AI knowledge", 205 | "industry-specific AI applications examples", 206 | "人工智能 入门 学习路径 lang:zh", 207 | "KI Grundlagen für Berufstätige lang:de", 208 | "künstliche Intelligenz ethische Fragen Einführung", 209 | "AI literacy career development practical guide" 210 | ] 211 | 212 | 213 | 214 | Now, process this query: 215 | Input Query: ${query} 216 | 217 | Let me think as a user: ${think} 218 | `; 219 | } 220 | 221 | export async function rewriteQuery( 222 | action: SearchAction, 223 | trackers: TrackerContext, 224 | schemaGen: Schemas, 225 | ): Promise<{ queries: string[] }> { 226 | try { 227 | const generator = new ObjectGeneratorSafe(trackers.tokenTracker); 228 | const allQueries = [...action.searchRequests]; 229 | 230 | const queryPromises = action.searchRequests.map(async (req) => { 231 | const prompt = getPrompt(req, action.think); 232 | const result = await generator.generateObject({ 233 | model: 'queryRewriter', 234 | schema: schemaGen.getQueryRewriterSchema(), 235 | prompt, 236 | }); 237 | trackers?.actionTracker.trackThink(result.object.think); 238 | return result.object.queries; 239 | }); 240 | 241 | const queryResults = await Promise.all(queryPromises); 242 | queryResults.forEach((queries) => allQueries.push(...queries)); 243 | console.log('queryRewriter', allQueries); 244 | return { queries: allQueries }; 245 | } catch (error) { 246 | console.error(`Error in queryRewriter`, error); 247 | throw error; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/tools/read.ts: -------------------------------------------------------------------------------- 1 | import https from 'https'; 2 | import { TokenTracker } from '../utils/token-tracker'; 3 | import { ReadResponse } from '../types'; 4 | import { JINA_API_KEY } from '../config'; 5 | 6 | export function readUrl( 7 | url: string, 8 | tracker?: TokenTracker, 9 | ): Promise<{ response: ReadResponse }> { 10 | return new Promise((resolve, reject) => { 11 | if (!url.trim()) { 12 | reject(new Error('URL cannot be empty')); 13 | return; 14 | } 15 | 16 | const data = JSON.stringify({ url }); 17 | 18 | const options = { 19 | hostname: 'r.jina.ai', 20 | port: 443, 21 | path: '/', 22 | method: 'POST', 23 | headers: { 24 | Accept: 'application/json', 25 | Authorization: `Bearer ${JINA_API_KEY}`, 26 | 'Content-Type': 'application/json', 27 | 'Content-Length': data.length, 28 | 'X-Retain-Images': 'none', 29 | 'X-Engine': 'direct', 30 | }, 31 | }; 32 | 33 | const req = https.request(options, (res) => { 34 | let responseData = ''; 35 | 36 | res.on('data', (chunk) => (responseData += chunk)); 37 | 38 | res.on('end', () => { 39 | // Check HTTP status code first 40 | if (res.statusCode && res.statusCode >= 400) { 41 | try { 42 | // Try to parse error message from response if available 43 | const errorResponse = JSON.parse(responseData); 44 | if (res.statusCode === 402) { 45 | reject( 46 | new Error( 47 | errorResponse.readableMessage || 'Insufficient balance', 48 | ), 49 | ); 50 | return; 51 | } 52 | reject( 53 | new Error( 54 | errorResponse.readableMessage || `HTTP Error ${res.statusCode}`, 55 | ), 56 | ); 57 | } catch (error: unknown) { 58 | // If parsing fails, just return the status code 59 | reject(new Error(`HTTP Error ${res.statusCode}`)); 60 | } 61 | return; 62 | } 63 | 64 | // Only parse JSON for successful responses 65 | let response: ReadResponse; 66 | try { 67 | response = JSON.parse(responseData) as ReadResponse; 68 | } catch (error: unknown) { 69 | reject( 70 | new Error( 71 | `Failed to parse response: ${error instanceof Error ? error.message : 'Unknown error'}`, 72 | ), 73 | ); 74 | return; 75 | } 76 | 77 | if (!response.data) { 78 | reject(new Error('Invalid response data')); 79 | return; 80 | } 81 | 82 | console.log('Read:', { 83 | title: response.data.title, 84 | url: response.data.url, 85 | tokens: response.data.usage?.tokens || 0, 86 | }); 87 | 88 | const tokens = response.data.usage?.tokens || 0; 89 | const tokenTracker = tracker || new TokenTracker(); 90 | tokenTracker.trackUsage('read', { 91 | totalTokens: tokens, 92 | promptTokens: url.length, 93 | completionTokens: tokens, 94 | }); 95 | 96 | resolve({ response }); 97 | }); 98 | }); 99 | 100 | // Add timeout handling 101 | req.setTimeout(30000, () => { 102 | req.destroy(); 103 | reject(new Error('Request timed out')); 104 | }); 105 | 106 | req.on('error', (error: Error) => { 107 | reject(new Error(`Request failed: ${error.message}`)); 108 | }); 109 | 110 | req.write(data); 111 | req.end(); 112 | }); 113 | } 114 | 115 | export function removeAllLineBreaks(text: string) { 116 | return text.replace(/(\r\n|\n|\r)/gm, ' '); 117 | } 118 | -------------------------------------------------------------------------------- /src/tools/serper-search.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { SERPER_API_KEY } from '../config'; 3 | 4 | import { SerperSearchResponse } from '../types'; 5 | 6 | export async function serperSearch( 7 | query: string, 8 | ): Promise<{ response: SerperSearchResponse }> { 9 | const response = await axios.post( 10 | 'https://google.serper.dev/search', 11 | { 12 | q: query, 13 | autocorrect: false, 14 | }, 15 | { 16 | headers: { 17 | 'X-API-KEY': SERPER_API_KEY, 18 | 'Content-Type': 'application/json', 19 | }, 20 | timeout: 10000, 21 | }, 22 | ); 23 | 24 | // Maintain the same return structure as the original code 25 | return { response: response.data }; 26 | } 27 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Action Types 2 | import { CoreAssistantMessage, CoreUserMessage, LanguageModelUsage } from 'ai'; 3 | 4 | type BaseAction = { 5 | action: 'search' | 'answer' | 'reflect' | 'visit' | 'coding'; 6 | think: string; 7 | }; 8 | 9 | export type SearchAction = BaseAction & { 10 | action: 'search'; 11 | searchRequests: string[]; 12 | }; 13 | 14 | export type AnswerAction = BaseAction & { 15 | action: 'answer'; 16 | answer: string; 17 | references: Array<{ 18 | exactQuote: string; 19 | url: string; 20 | }>; 21 | isFinal?: boolean; 22 | mdAnswer?: string; 23 | }; 24 | 25 | export type KnowledgeItem = { 26 | question: string; 27 | answer: string; 28 | references?: 29 | | Array<{ 30 | exactQuote: string; 31 | url: string; 32 | }> 33 | | Array; 34 | type: 'qa' | 'side-info' | 'chat-history' | 'url' | 'coding'; 35 | updated: string; 36 | sourceCode?: string; 37 | }; 38 | 39 | export type ReflectAction = BaseAction & { 40 | action: 'reflect'; 41 | questionsToAnswer: string[]; 42 | }; 43 | 44 | export type VisitAction = BaseAction & { 45 | action: 'visit'; 46 | URLTargets: string[]; 47 | }; 48 | 49 | export type CodingAction = BaseAction & { 50 | action: 'coding'; 51 | codingIssue: string; 52 | }; 53 | 54 | export type StepAction = 55 | | SearchAction 56 | | AnswerAction 57 | | ReflectAction 58 | | VisitAction 59 | | CodingAction; 60 | 61 | export type EvaluationType = 62 | | 'definitive' 63 | | 'freshness' 64 | | 'plurality' 65 | | 'attribution' 66 | | 'completeness'; 67 | 68 | // Following Vercel AI SDK's token counting interface 69 | export interface TokenUsage { 70 | tool: string; 71 | usage: LanguageModelUsage; 72 | } 73 | 74 | export interface SearchResponse { 75 | code: number; 76 | status: number; 77 | data: Array<{ 78 | title: string; 79 | description: string; 80 | url: string; 81 | content: string; 82 | usage: { tokens: number }; 83 | }> | null; 84 | name?: string; 85 | message?: string; 86 | readableMessage?: string; 87 | } 88 | 89 | export interface BraveSearchResponse { 90 | web: { 91 | results: Array<{ 92 | title: string; 93 | description: string; 94 | url: string; 95 | }>; 96 | }; 97 | } 98 | 99 | export interface SerperSearchResponse { 100 | knowledgeGraph?: { 101 | title: string; 102 | type: string; 103 | website: string; 104 | imageUrl: string; 105 | description: string; 106 | descriptionSource: string; 107 | descriptionLink: string; 108 | attributes: { [k: string]: string }; 109 | }; 110 | organic: { 111 | title: string; 112 | link: string; 113 | snippet: string; 114 | date: string; 115 | siteLinks?: { title: string; link: string }[]; 116 | position: number; 117 | }[]; 118 | topStories?: { 119 | title: string; 120 | link: string; 121 | source: string; 122 | data: string; 123 | imageUrl: string; 124 | }[]; 125 | relatedSearches?: string[]; 126 | credits: number; 127 | } 128 | 129 | export interface ReadResponse { 130 | code: number; 131 | status: number; 132 | data?: { 133 | title: string; 134 | description: string; 135 | url: string; 136 | content: string; 137 | usage: { tokens: number }; 138 | }; 139 | name?: string; 140 | message?: string; 141 | readableMessage?: string; 142 | } 143 | 144 | export type EvaluationResponse = { 145 | pass: boolean; 146 | think: string; 147 | type?: EvaluationType; 148 | freshness_analysis?: { 149 | days_ago: number; 150 | max_age_days?: number; 151 | }; 152 | plurality_analysis?: { 153 | count_expected?: number; 154 | count_provided: number; 155 | }; 156 | attribution_analysis?: { 157 | sources_provided: boolean; 158 | sources_verified: boolean; 159 | quotes_accurate: boolean; 160 | }; 161 | completeness_analysis?: { 162 | aspects_expected: string; 163 | aspects_provided: string; 164 | }; 165 | }; 166 | 167 | export type CodeGenResponse = { 168 | think: string; 169 | code: string; 170 | }; 171 | 172 | export type ErrorAnalysisResponse = { 173 | recap: string; 174 | blame: string; 175 | improvement: string; 176 | questionsToAnswer: string[]; 177 | }; 178 | 179 | export type SearchResult = 180 | | { title: string; url: string; description: string } 181 | | { title: string; link: string; snippet: string }; 182 | 183 | // OpenAI API Types 184 | export interface Model { 185 | id: string; 186 | object: 'model'; 187 | created: number; 188 | owned_by: string; 189 | } 190 | 191 | export interface ChatCompletionRequest { 192 | model: string; 193 | messages: Array; 194 | stream?: boolean; 195 | reasoning_effort?: 'low' | 'medium' | 'high' | null; 196 | max_completion_tokens?: number | null; 197 | 198 | budget_tokens?: number | null; 199 | max_attempts?: number | null; 200 | } 201 | 202 | export interface ChatCompletionResponse { 203 | id: string; 204 | object: 'chat.completion'; 205 | created: number; 206 | model: string; 207 | system_fingerprint: string; 208 | choices: Array<{ 209 | index: number; 210 | message: { 211 | role: 'assistant'; 212 | content: string; 213 | }; 214 | logprobs: null; 215 | finish_reason: 'stop'; 216 | }>; 217 | usage: { 218 | prompt_tokens: number; 219 | completion_tokens: number; 220 | total_tokens: number; 221 | }; 222 | visitedURLs?: string[]; 223 | readURLs?: string[]; 224 | } 225 | 226 | export interface ChatCompletionChunk { 227 | id: string; 228 | object: 'chat.completion.chunk'; 229 | created: number; 230 | model: string; 231 | system_fingerprint: string; 232 | choices: Array<{ 233 | index: number; 234 | delta: { 235 | role?: 'assistant'; 236 | content?: string; 237 | }; 238 | logprobs: null; 239 | finish_reason: null | 'stop'; 240 | }>; 241 | usage?: any; 242 | visitedURLs?: string[]; 243 | readURLs?: string[]; 244 | } 245 | 246 | // Tracker Types 247 | import { TokenTracker } from './utils/token-tracker'; 248 | import { ActionTracker } from './utils/action-tracker'; 249 | 250 | export interface TrackerContext { 251 | tokenTracker: TokenTracker; 252 | actionTracker: ActionTracker; 253 | } 254 | -------------------------------------------------------------------------------- /src/utils/action-tracker.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { StepAction } from '../types'; 3 | import { getI18nText } from './text-tools'; 4 | 5 | interface ActionState { 6 | thisStep: StepAction; 7 | gaps: string[]; 8 | badAttempts: number; 9 | totalStep: number; 10 | } 11 | 12 | export class ActionTracker extends EventEmitter { 13 | private state: ActionState = { 14 | thisStep: { action: 'answer', answer: '', references: [], think: '' }, 15 | gaps: [], 16 | badAttempts: 0, 17 | totalStep: 0, 18 | }; 19 | 20 | trackAction(newState: Partial) { 21 | this.state = { ...this.state, ...newState }; 22 | this.emit('action', this.state.thisStep); 23 | } 24 | 25 | trackThink(think: string, lang?: string, params = {}) { 26 | if (lang) { 27 | think = getI18nText(think, lang, params); 28 | } 29 | this.state = { ...this.state, thisStep: { ...this.state.thisStep, think } }; 30 | this.emit('action', this.state.thisStep); 31 | } 32 | 33 | getState(): ActionState { 34 | return { ...this.state }; 35 | } 36 | 37 | reset() { 38 | this.state = { 39 | thisStep: { action: 'answer', answer: '', references: [], think: '' }, 40 | gaps: [], 41 | badAttempts: 0, 42 | totalStep: 0, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/utils/i18n.json: -------------------------------------------------------------------------------- 1 | { 2 | "en": { 3 | "eval_first": "But wait, let me evaluate the answer first.", 4 | "search_for": "Let me search for ${keywords} to gather more information.", 5 | "read_for": "Let me read ${urls} to gather more information.", 6 | "read_for_verify": "Let me fetch the source content to verify the answer." 7 | }, 8 | "zh-CN": { 9 | "eval_first": "等等,让我先自己评估一下答案。", 10 | "search_for": "让我搜索${keywords}来获取更多信息。", 11 | "read_for": "让我读取网页${urls}来获取更多信息。", 12 | "read_for_verify": "让我读取源网页内容来验证答案。" 13 | }, 14 | "zh-TW": { 15 | "eval_first": "等等,讓我先評估一下答案。", 16 | "search_for": "讓我搜索${keywords}來獲取更多信息。", 17 | "read_for": "讓我閱讀${urls}來獲取更多信息。", 18 | "read_for_verify": "讓我獲取源內容來驗證答案。" 19 | }, 20 | "ja": { 21 | "eval_first": "ちょっと待って、まず答えを評価します。", 22 | "search_for": "キーワード${keywords}で検索して、情報を集めます。", 23 | "read_for": "URL${urls}を読んで、情報を集めます。", 24 | "read_for_verify": "答えを確認するために、ソースコンテンツを取得します。" 25 | }, 26 | "ko": { 27 | "eval_first": "잠시만요, 먼저 답변을 평가해 보겠습니다.", 28 | "search_for": "키워드 ${keywords}로 검색하여 더 많은 정보를 수집하겠습니다.", 29 | "read_for": "URL ${urls}을 읽어 더 많은 정보를 수집하겠습니다.", 30 | "read_for_verify": "답변을 확인하기 위해 소스 콘텐츠를 가져오겠습니다." 31 | }, 32 | "fr": { 33 | "eval_first": "Un instant, je vais d'abord évaluer la réponse.", 34 | "search_for": "Je vais rechercher ${keywords} pour obtenir plus d'informations.", 35 | "read_for": "Je vais lire ${urls} pour obtenir plus d'informations.", 36 | "read_for_verify": "Je vais récupérer le contenu source pour vérifier la réponse." 37 | }, 38 | "de": { 39 | "eval_first": "Einen Moment, ich werde die Antwort zuerst evaluieren.", 40 | "search_for": "Ich werde nach ${keywords} suchen, um weitere Informationen zu sammeln.", 41 | "read_for": "Ich werde ${urls} lesen, um weitere Informationen zu sammeln.", 42 | "read_for_verify": "Ich werde den Quellinhalt abrufen, um die Antwort zu überprüfen." 43 | }, 44 | "es": { 45 | "eval_first": "Un momento, voy a evaluar la respuesta primero.", 46 | "search_for": "Voy a buscar ${keywords} para recopilar más información.", 47 | "read_for": "Voy a leer ${urls} para recopilar más información.", 48 | "read_for_verify": "Voy a obtener el contenido fuente para verificar la respuesta." 49 | }, 50 | "it": { 51 | "eval_first": "Un attimo, valuterò prima la risposta.", 52 | "search_for": "Cercherò ${keywords} per raccogliere ulteriori informazioni.", 53 | "read_for": "Leggerò ${urls} per raccogliere ulteriori informazioni.", 54 | "read_for_verify": "Recupererò il contenuto sorgente per verificare la risposta." 55 | }, 56 | "pt": { 57 | "eval_first": "Um momento, vou avaliar a resposta primeiro.", 58 | "search_for": "Vou pesquisar ${keywords} para reunir mais informações.", 59 | "read_for": "Vou ler ${urls} para reunir mais informações.", 60 | "read_for_verify": "Vou buscar o conteúdo da fonte para verificar a resposta." 61 | }, 62 | "ru": { 63 | "eval_first": "Подождите, я сначала оценю ответ.", 64 | "search_for": "Дайте мне поискать ${keywords} для сбора дополнительной информации.", 65 | "read_for": "Дайте мне прочитать ${urls} для сбора дополнительной информации.", 66 | "read_for_verify": "Дайте мне получить исходный контент для проверки ответа." 67 | }, 68 | "ar": { 69 | "eval_first": "لكن انتظر، دعني أقوم بتقييم الإجابة أولاً.", 70 | "search_for": "دعني أبحث عن ${keywords} لجمع المزيد من المعلومات.", 71 | "read_for": "دعني أقرأ ${urls} لجمع المزيد من المعلومات.", 72 | "read_for_verify": "دعني أحضر محتوى المصدر للتحقق من الإجابة." 73 | }, 74 | "nl": { 75 | "eval_first": "Een moment, ik zal het antwoord eerst evalueren.", 76 | "search_for": "Ik zal zoeken naar ${keywords} om meer informatie te verzamelen.", 77 | "read_for": "Ik zal ${urls} lezen om meer informatie te verzamelen.", 78 | "read_for_verify": "Ik zal de broninhoud ophalen om het antwoord te verifiëren." 79 | }, 80 | "zh": { 81 | "eval_first": "等等,让我先评估一下答案。", 82 | "search_for": "让我搜索${keywords}来获取更多信息。", 83 | "read_for": "让我阅读${urls}来获取更多信息。", 84 | "read_for_verify": "让我获取源内容来验证答案。" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/safe-generator.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { generateObject, LanguageModelUsage, NoObjectGeneratedError } from 'ai'; 3 | import { TokenTracker } from './token-tracker'; 4 | import { getModel, ToolName, getToolConfig } from '../config'; 5 | 6 | interface GenerateObjectResult { 7 | object: T; 8 | usage: LanguageModelUsage; 9 | } 10 | 11 | interface GenerateOptions { 12 | model: ToolName; 13 | schema: z.ZodType; 14 | prompt?: string; 15 | system?: string; 16 | messages?: any; 17 | } 18 | 19 | export class ObjectGeneratorSafe { 20 | private tokenTracker: TokenTracker; 21 | 22 | constructor(tokenTracker?: TokenTracker) { 23 | this.tokenTracker = tokenTracker || new TokenTracker(); 24 | } 25 | 26 | async generateObject( 27 | options: GenerateOptions, 28 | ): Promise> { 29 | const { model, schema, prompt, system, messages } = options; 30 | 31 | try { 32 | // Primary attempt with main model 33 | const result = await generateObject({ 34 | model: getModel(model), 35 | schema, 36 | prompt, 37 | system, 38 | messages, 39 | maxTokens: getToolConfig(model).maxTokens, 40 | temperature: getToolConfig(model).temperature, 41 | }); 42 | 43 | this.tokenTracker.trackUsage(model, result.usage); 44 | return result; 45 | } catch (error) { 46 | // First fallback: Try manual JSON parsing of the error response 47 | try { 48 | const errorResult = await this.handleGenerateObjectError(error); 49 | this.tokenTracker.trackUsage(model, errorResult.usage); 50 | return errorResult; 51 | } catch (parseError) { 52 | // Second fallback: Try with fallback model if provided 53 | const fallbackModel = getModel('fallback'); 54 | if (NoObjectGeneratedError.isInstance(parseError)) { 55 | const failedOutput = (parseError as any).text; 56 | console.error( 57 | `${model} failed on object generation ${failedOutput} -> manual parsing failed again -> trying fallback model`, 58 | fallbackModel, 59 | ); 60 | try { 61 | const fallbackResult = await generateObject({ 62 | model: fallbackModel, 63 | schema, 64 | prompt: `Extract the desired information from this text: \n ${failedOutput}`, 65 | maxTokens: getToolConfig('fallback').maxTokens, 66 | temperature: getToolConfig('fallback').temperature, 67 | }); 68 | 69 | this.tokenTracker.trackUsage(model, fallbackResult.usage); 70 | return fallbackResult; 71 | } catch (fallbackError) { 72 | // If fallback model also fails, try parsing its error response 73 | return await this.handleGenerateObjectError(fallbackError); 74 | } 75 | } 76 | 77 | // If no fallback model or all attempts failed, throw the original error 78 | throw error; 79 | } 80 | } 81 | } 82 | 83 | private async handleGenerateObjectError( 84 | error: unknown, 85 | ): Promise> { 86 | if (NoObjectGeneratedError.isInstance(error)) { 87 | // console.error( 88 | // 'Object not generated according to schema, fallback to manual JSON parsing', 89 | // ); 90 | try { 91 | const partialResponse = JSON.parse((error as any).text); 92 | return { 93 | object: partialResponse as T, 94 | usage: (error as any).usage, 95 | }; 96 | } catch (parseError) { 97 | throw error; 98 | } 99 | } 100 | throw error; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/utils/text-tools.ts: -------------------------------------------------------------------------------- 1 | import { AnswerAction } from '../types'; 2 | import i18nJSON from './i18n.json'; 3 | 4 | export function buildMdFromAnswer(answer: AnswerAction) { 5 | // Standard footnote regex 6 | const footnoteRegex = /\[\^(\d+)]/g; 7 | 8 | // New regex to catch grouped footnotes like [^1, ^2, ^3] or [^1,^2,^3] 9 | const groupedFootnoteRegex = /\[\^(\d+)(?:,\s*\^(\d+))+]/g; 10 | 11 | // Helper function to format references 12 | const formatReferences = (refs: typeof answer.references) => { 13 | return refs 14 | .map((ref, i) => { 15 | const cleanQuote = ref.exactQuote 16 | .replace(/[^\p{L}\p{N}\s]/gu, ' ') 17 | .replace(/\s+/g, ' '); 18 | 19 | const citation = `[^${i + 1}]: ${cleanQuote}`; 20 | 21 | if (!ref.url?.startsWith('http')) return citation; 22 | 23 | const domainName = new URL(ref.url).hostname.replace('www.', ''); 24 | return `${citation} [${domainName}](${ref.url})`; 25 | }) 26 | .join('\n\n'); 27 | }; 28 | 29 | // First case: no references - remove any footnote citations 30 | if (!answer.references?.length) { 31 | return answer.answer 32 | .replace(groupedFootnoteRegex, (match) => { 33 | // Extract all numbers from the grouped footnote 34 | const numbers = match.match(/\d+/g) || []; 35 | return numbers.map((num) => `[^${num}]`).join(', '); 36 | }) 37 | .replace(footnoteRegex, ''); 38 | } 39 | 40 | // Fix grouped footnotes first 41 | const processedAnswer = answer.answer.replace( 42 | groupedFootnoteRegex, 43 | (match) => { 44 | // Extract all numbers from the grouped footnote 45 | const numbers = match.match(/\d+/g) || []; 46 | return numbers.map((num) => `[^${num}]`).join(', '); 47 | }, 48 | ); 49 | 50 | // Now extract all footnotes from the processed answer 51 | const footnotes: string[] = []; 52 | let match; 53 | while ((match = footnoteRegex.exec(processedAnswer)) !== null) { 54 | footnotes.push(match[1]); 55 | } 56 | 57 | // No footnotes in answer but we have references - append them at the end 58 | if (footnotes.length === 0) { 59 | const appendedCitations = Array.from( 60 | { length: answer.references.length }, 61 | (_, i) => `[^${i + 1}]`, 62 | ).join(''); 63 | 64 | const references = formatReferences(answer.references); 65 | 66 | return ` 67 | ${processedAnswer} 68 | 69 | ⁜${appendedCitations} 70 | 71 | ${references} 72 | `.trim(); 73 | } 74 | 75 | // Check if correction is needed 76 | const needsCorrection = 77 | (footnotes.length === answer.references.length && 78 | footnotes.every((n) => n === footnotes[0])) || 79 | (footnotes.every((n) => n === footnotes[0]) && 80 | parseInt(footnotes[0]) > answer.references.length) || 81 | (footnotes.length > 0 && 82 | footnotes.every((n) => parseInt(n) > answer.references.length)); 83 | 84 | // New case: we have more references than footnotes 85 | if (answer.references.length > footnotes.length && !needsCorrection) { 86 | // Get the used indices 87 | const usedIndices = new Set(footnotes.map((n) => parseInt(n))); 88 | 89 | // Create citations for unused references 90 | const unusedReferences = Array.from( 91 | { length: answer.references.length }, 92 | (_, i) => (!usedIndices.has(i + 1) ? `[^${i + 1}]` : ''), 93 | ).join(''); 94 | 95 | return ` 96 | ${processedAnswer} 97 | 98 | ⁜${unusedReferences} 99 | 100 | ${formatReferences(answer.references)} 101 | `.trim(); 102 | } 103 | 104 | if (!needsCorrection) { 105 | return ` 106 | ${processedAnswer} 107 | 108 | ${formatReferences(answer.references)} 109 | `.trim(); 110 | } 111 | 112 | // Apply correction: sequentially number the footnotes 113 | let currentIndex = 0; 114 | const correctedAnswer = processedAnswer.replace( 115 | footnoteRegex, 116 | () => `[^${++currentIndex}]`, 117 | ); 118 | 119 | return ` 120 | ${correctedAnswer} 121 | 122 | ${formatReferences(answer.references)} 123 | `.trim(); 124 | } 125 | 126 | export const removeExtraLineBreaks = (text: string) => { 127 | return text.replace(/\n{2,}/gm, '\n\n'); 128 | }; 129 | 130 | export function chooseK(a: string[], k: number) { 131 | // randomly sample k from `a` without repetition 132 | return a.sort(() => 0.5 - Math.random()).slice(0, k); 133 | } 134 | 135 | export function removeHTMLtags(text: string) { 136 | return text.replace(/<[^>]*>?/gm, ''); 137 | } 138 | 139 | export function getI18nText( 140 | key: string, 141 | lang = 'en', 142 | params: Record = {}, 143 | ) { 144 | // 获取i18n数据 145 | const i18nData = i18nJSON as Record; 146 | // 确保语言代码存在,如果不存在则使用英语作为后备 147 | if (!i18nData[lang]) { 148 | console.error(`Language '${lang}' not found, falling back to English.`); 149 | lang = 'en'; 150 | } 151 | 152 | // 获取对应语言的文本 153 | let text = i18nData[lang][key]; 154 | 155 | // 如果文本不存在,则使用英语作为后备 156 | if (!text) { 157 | console.error( 158 | `Key '${key}' not found for language '${lang}', falling back to English.`, 159 | ); 160 | text = i18nData['en'][key]; 161 | 162 | // 如果英语版本也不存在,则返回键名 163 | if (!text) { 164 | console.error(`Key '${key}' not found for English either.`); 165 | return key; 166 | } 167 | } 168 | 169 | // 替换模板中的变量 170 | if (params) { 171 | Object.keys(params).forEach((paramKey) => { 172 | text = text.replace(`\${${paramKey}}`, params[paramKey]); 173 | }); 174 | } 175 | 176 | return text; 177 | } 178 | -------------------------------------------------------------------------------- /src/utils/token-tracker.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { TokenUsage } from '../types'; 4 | import { LanguageModelUsage } from 'ai'; 5 | import { inspect } from 'util'; 6 | 7 | export class TokenTracker extends EventEmitter { 8 | private usages: TokenUsage[] = []; 9 | private budget?: number; 10 | 11 | constructor(budget?: number) { 12 | super(); 13 | this.budget = budget; 14 | 15 | if ('asyncLocalContext' in process) { 16 | const asyncLocalContext = process.asyncLocalContext as any; 17 | this.on('usage', () => { 18 | if (asyncLocalContext.available()) { 19 | asyncLocalContext.ctx.chargeAmount = this.getTotalUsage().totalTokens; 20 | } 21 | }); 22 | } 23 | } 24 | 25 | trackUsage(tool: string, usage: LanguageModelUsage) { 26 | const u = { tool, usage }; 27 | this.usages.push(u); 28 | this.emit('usage', usage); 29 | } 30 | 31 | getTotalUsage(): LanguageModelUsage { 32 | return this.usages.reduce( 33 | (acc, { usage }) => { 34 | acc.promptTokens += usage.promptTokens; 35 | acc.completionTokens += usage.completionTokens; 36 | acc.totalTokens += usage.totalTokens; 37 | return acc; 38 | }, 39 | { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, 40 | ); 41 | } 42 | 43 | getTotalUsageSnakeCase(): { 44 | prompt_tokens: number; 45 | completion_tokens: number; 46 | total_tokens: number; 47 | } { 48 | return this.usages.reduce( 49 | (acc, { usage }) => { 50 | acc.prompt_tokens += usage.promptTokens; 51 | acc.completion_tokens += usage.completionTokens; 52 | acc.total_tokens += usage.totalTokens; 53 | return acc; 54 | }, 55 | { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, 56 | ); 57 | } 58 | 59 | getUsageBreakdown(): Record { 60 | return this.usages.reduce( 61 | (acc, { tool, usage }) => { 62 | acc[tool] = (acc[tool] || 0) + usage.totalTokens; 63 | return acc; 64 | }, 65 | {} as Record, 66 | ); 67 | } 68 | 69 | printSummary() { 70 | const breakdown = this.getUsageBreakdown(); 71 | console.log( 72 | 'Token Usage Summary:', 73 | inspect( 74 | { 75 | budget: this.budget, 76 | total: this.getTotalUsage(), 77 | breakdown, 78 | }, 79 | { 80 | depth: null, 81 | colors: true, 82 | numericSeparator: true, 83 | }, 84 | ), 85 | ); 86 | } 87 | 88 | reset() { 89 | this.usages = []; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/url-tools.ts: -------------------------------------------------------------------------------- 1 | import { SearchResult } from '../types'; 2 | 3 | export function normalizeUrl(urlString: string, debug = false): string { 4 | if (!urlString?.trim()) { 5 | throw new Error('Empty URL'); 6 | } 7 | 8 | urlString = urlString.trim(); 9 | 10 | if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(urlString)) { 11 | urlString = 'https://' + urlString; 12 | } 13 | 14 | try { 15 | const url = new URL(urlString); 16 | 17 | url.hostname = url.hostname.toLowerCase(); 18 | if (url.hostname.startsWith('www.')) { 19 | url.hostname = url.hostname.slice(4); 20 | } 21 | 22 | if ( 23 | (url.protocol === 'http:' && url.port === '80') || 24 | (url.protocol === 'https:' && url.port === '443') 25 | ) { 26 | url.port = ''; 27 | } 28 | 29 | // Path normalization with error tracking 30 | url.pathname = 31 | url.pathname 32 | .split('/') 33 | .map((segment) => { 34 | try { 35 | return decodeURIComponent(segment); 36 | } catch (e) { 37 | if (debug) 38 | console.error(`Failed to decode path segment: ${segment}`, e); 39 | return segment; 40 | } 41 | }) 42 | .join('/') 43 | .replace(/\/+/g, '/') 44 | .replace(/\/+$/, '') || '/'; 45 | 46 | // Query parameter normalization with error details 47 | const searchParams = new URLSearchParams(url.search); 48 | const sortedParams = Array.from(searchParams.entries()) 49 | .map(([key, value]) => { 50 | if (value === '') return [key, '']; 51 | try { 52 | const decodedValue = decodeURIComponent(value); 53 | if (encodeURIComponent(decodedValue) === value) { 54 | return [key, decodedValue]; 55 | } 56 | } catch (e) { 57 | if (debug) 58 | console.error(`Failed to decode query param ${key}=${value}`, e); 59 | } 60 | return [key, value]; 61 | }) 62 | .sort(([keyA], [keyB]) => keyA.localeCompare(keyB)) 63 | .filter(([key]) => key !== ''); 64 | 65 | url.search = new URLSearchParams(sortedParams).toString(); 66 | 67 | // Fragment handling with validation 68 | if ( 69 | url.hash === '#' || 70 | url.hash === '#top' || 71 | url.hash === '#/' || 72 | !url.hash 73 | ) { 74 | url.hash = ''; 75 | } else if (url.hash) { 76 | try { 77 | const decodedHash = decodeURIComponent(url.hash.slice(1)); 78 | const encodedBack = encodeURIComponent(decodedHash); 79 | // Only use decoded version if it's safe 80 | if (encodedBack === url.hash.slice(1)) { 81 | url.hash = '#' + decodedHash; 82 | } 83 | } catch (e) { 84 | if (debug) console.error(`Failed to decode fragment: ${url.hash}`, e); 85 | } 86 | } 87 | 88 | let normalizedUrl = url.toString(); 89 | 90 | // Final URL normalization with validation 91 | try { 92 | const decodedUrl = decodeURIComponent(normalizedUrl); 93 | const encodedBack = encodeURIComponent(decodedUrl); 94 | // Only use decoded version if it's safe 95 | if (encodedBack === normalizedUrl) { 96 | normalizedUrl = decodedUrl; 97 | } 98 | } catch (e) { 99 | if (debug) console.error('Failed to decode final URL', e); 100 | } 101 | 102 | return normalizedUrl; 103 | } catch (error) { 104 | // Main URL parsing error - this one we should throw 105 | throw new Error(`Invalid URL "${urlString}": ${error}`); 106 | } 107 | } 108 | 109 | export function getUnvisitedURLs( 110 | allURLs: Record, 111 | visitedURLs: string[], 112 | ): SearchResult[] { 113 | return Object.entries(allURLs) 114 | .filter(([url]) => !visitedURLs.includes(url)) 115 | .map(([, result]) => result); 116 | } 117 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "node16", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "sourceMap": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "resolveJsonModule": true, 15 | "jsx": "react-jsx" 16 | }, 17 | "include": ["src/**/*"], 18 | "exclude": ["jina-ai/**/*", "**/__tests__/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /workflow.md: -------------------------------------------------------------------------------- 1 | ```mermaid 2 | flowchart 3 | Start --> CL(Calculate Language) 4 | Start --> CQ(Calculate Questions) 5 | CQ --> DQM(Determine Question Metrics) 6 | ``` 7 | --------------------------------------------------------------------------------