From 98d1e8e528eaae7dbcf78b0e251114fd574e64dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:45:05 +0000 Subject: [PATCH 1/3] chore(deps): bump typescript from 5.9.3 to 6.0.2 Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2. - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2) --- updated-dependencies: - dependency-name: typescript dependency-version: 6.0.2 dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- package-lock.json | 158 +++++++++++++++++++++++----------------------- package.json | 2 +- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5e80bf3..e1d90fa6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,7 +72,7 @@ "shadcn": "^4.1.2", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "5.9.3", + "typescript": "6.0.2", "vitest": "^4.1.2" }, "engines": { @@ -6933,20 +6933,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6956,9 +6956,9 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { @@ -6972,16 +6972,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "engines": { @@ -6993,18 +6993,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "engines": { @@ -7015,18 +7015,18 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7037,9 +7037,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -7050,21 +7050,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7075,13 +7075,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -7093,21 +7093,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7117,7 +7117,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { @@ -7134,16 +7134,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7154,17 +7154,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -17545,9 +17545,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", - "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -18249,9 +18249,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -18263,16 +18263,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -18283,7 +18283,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/unbox-primitive": { diff --git a/package.json b/package.json index f7b3e94e..ff238ecd 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "shadcn": "^4.1.2", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "5.9.3", + "typescript": "6.0.2", "vitest": "^4.1.2" } } From 39c66e220f2157309cf35d6e95ca20ccfa954a91 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 3 Apr 2026 00:27:23 +0000 Subject: [PATCH 2/3] fix: remove redundant nullish fallback for TS6 Agent-Logs-Url: https://github.com/SpectrAI-Initiative/InnoClaw/sessions/28d1898a-e0aa-4846-b018-2108b60b61bf Co-authored-by: tangshixiang <15044508+tangshixiang@users.noreply.github.com> --- src/lib/deep-research/memory-fabric.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/deep-research/memory-fabric.ts b/src/lib/deep-research/memory-fabric.ts index edb10c3d..4c6e358a 100644 --- a/src/lib/deep-research/memory-fabric.ts +++ b/src/lib/deep-research/memory-fabric.ts @@ -228,7 +228,7 @@ function buildResearchMemoryProfile({ const openQuestions = dedupeStrings([ ...(checkpoint?.openQuestions ?? []), ...((claimMap?.gaps ?? []).map((gap) => gap.topic)), - ...((review?.literatureGaps ?? []) ?? []), + ...(review?.literatureGaps ?? []), ]).slice(0, 8); const activeHypotheses = dedupeStrings([ @@ -565,12 +565,12 @@ function buildResearchMemorySnapshot( ]).slice(0, 6); const contestedFacts = dedupeStrings([ ...((latestClaimMap?.contradictions ?? []).map((contradiction) => contradiction.description)), - ...((latestReview?.openIssues ?? []) ?? []), + ...(latestReview?.openIssues ?? []), ]).slice(0, 6); const unresolvedGaps = dedupeStrings([ ...profile.openQuestions, ...((latestClaimMap?.gaps ?? []).map((gap) => gap.topic)), - ...((latestReview?.literatureGaps ?? []) ?? []), + ...(latestReview?.literatureGaps ?? []), ]).slice(0, 8); const focusAreas = dedupeStrings(items.flatMap((item) => item.tags)).slice(0, 8); const relatedArtifactIds = dedupeStrings( From 2e6ff1977e27facc4e97d6cfcd1fa140c7fa867f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 16:15:15 +0000 Subject: [PATCH 3/3] merge main into dependabot/typescript-6.0.2 and resolve conflicts Agent-Logs-Url: https://github.com/SpectrAI-Initiative/InnoClaw/sessions/28002077-9206-488e-842d-c56424686c17 Co-authored-by: tangshixiang <15044508+tangshixiang@users.noreply.github.com> --- .agents/plugins/marketplace.json | 20 + .claude/skills/paper-discussion-full/SKILL.md | 33 +- .claude/skills/paper-discussion/SKILL.md | 31 +- .../skills/research-ideation-full/SKILL.md | 33 +- .dev.pid | 1 + .env.example | 10 +- .github/workflows/docs.yml | 2 +- AGENTS.md | 217 ++ CLAUDE.md | 7 +- Dockerfile | 4 + README.md | 78 +- docs/README_CN.md | 71 +- docs/README_DE.md | 75 +- docs/README_FR.md | 75 +- docs/README_JA.md | 75 +- docs/_static/custom.css | 40 + .../development/workflow-overview-en.png | Bin 0 -> 128215 bytes .../development/workflow-overview-zh.png | Bin 0 -> 126244 bytes .../_templates/components/foot-copyright.html | 13 + docs/conf.py | 27 +- docs/development/agent-development.md | 142 ++ docs/development/collaboration.md | 143 ++ docs/development/contributing.md | 75 +- docs/development/readme-homepage-redesign.md | 4 + docs/development/repository-guidelines.md | 56 + docs/getting-started/environment-variables.md | 3 +- docs/index.md | 15 +- .../development/agent-development.po | 452 ++++ .../LC_MESSAGES/development/collaboration.po | 339 +++ .../LC_MESSAGES/development/contributing.po | 213 +- .../development/repository-guidelines.po | 222 ++ docs/troubleshooting/faq.md | 4 +- docs/usage/configuration.md | 6 +- drizzle.config.ts | 7 +- drizzle/0016_auth_users.sql | 47 + drizzle/meta/_journal.json | 9 +- logs/dev.log | 19 + middleware.ts | 101 + next.config.ts | 34 +- package-lock.json | 940 ++++---- package.json | 47 +- .../innoclaw-cli/.codex-plugin/plugin.json | 46 + plugins/innoclaw-cli/README.md | 39 + plugins/innoclaw-cli/agents/openai.yaml | 9 + .../innoclaw-cli/assets/innoclaw-cli-logo.svg | 7 + .../assets/innoclaw-cli-small.svg | 5 + plugins/innoclaw-cli/scripts/innoclaw-cli.mjs | 263 +++ .../innoclaw-cli/skills/innoclaw-cli/SKILL.md | 49 + scripts/docker-entrypoint.sh | 23 +- src/app/admin/users/page.tsx | 215 ++ src/app/api/admin/users/route.ts | 189 ++ src/app/api/agent/buddy/route.ts | 101 + src/app/api/agent/memory/route.ts | 168 ++ src/app/api/agent/route.ts | 26 +- src/app/api/auth/login/route.ts | 61 + src/app/api/auth/logout/route.ts | 9 + src/app/api/auth/me/route.ts | 15 + src/app/api/auth/register/route.ts | 66 + src/app/api/chat/route.ts | 5 + src/app/api/cluster/operations/route.ts | 8 + src/app/api/daily-report/route.ts | 5 + .../api/datasets/[datasetId]/cancel/route.ts | 26 +- .../api/datasets/[datasetId]/pause/route.ts | 26 +- .../api/datasets/[datasetId]/preview/route.ts | 35 +- .../api/datasets/[datasetId]/refresh/route.ts | 26 +- .../api/datasets/[datasetId]/retry/route.ts | 28 +- src/app/api/datasets/[datasetId]/route.ts | 41 +- .../api/datasets/[datasetId]/status/route.ts | 34 +- .../datasets/[datasetId]/workspaces/route.ts | 45 +- src/app/api/datasets/import-local/route.ts | 13 + src/app/api/datasets/modelscope-info/route.ts | 6 + src/app/api/datasets/repo-info/route.ts | 6 + src/app/api/datasets/route.ts | 26 +- .../sessions/[id]/approve/route.ts | 5 + .../[id]/artifacts/[artifactId]/route.ts | 12 +- .../sessions/[id]/artifacts/route.ts | 5 + .../sessions/[id]/confirm/route.ts | 5 + .../sessions/[id]/events/route.ts | 5 + .../sessions/[id]/executions/route.ts | 5 + .../sessions/[id]/export/route.ts | 9 +- .../sessions/[id]/message/route.test.ts | 7 + .../sessions/[id]/message/route.ts | 5 + .../sessions/[id]/messages/route.ts | 5 + .../sessions/[id]/nodes/route.ts | 5 + .../api/deep-research/sessions/[id]/route.ts | 91 +- .../deep-research/sessions/[id]/run/route.ts | 5 + src/app/api/deep-research/sessions/route.ts | 9 + src/app/api/files/browse/route.ts | 21 +- src/app/api/files/copy/route.ts | 17 +- src/app/api/files/delete/route.ts | 17 +- src/app/api/files/extract-article/route.ts | 6 + src/app/api/files/mkdir/route.ts | 20 +- src/app/api/files/move/route.ts | 17 +- src/app/api/files/raw/route.ts | 21 +- src/app/api/files/read/route.ts | 21 +- src/app/api/files/rename/route.ts | 17 +- src/app/api/files/upload/route.ts | 17 +- src/app/api/files/write/route.ts | 17 +- src/app/api/generate/route.ts | 5 + src/app/api/git/pull/route.ts | 5 + src/app/api/git/status/route.ts | 5 + src/app/api/notes/[noteId]/route.ts | 17 +- src/app/api/notes/route.ts | 34 +- .../extract-paper-content-runtime.test.ts | 25 + .../api/paper-study/extract-paper-content.ts | 18 +- .../extract-paper-text-runtime.test.ts | 19 + src/app/api/paper-study/extract-paper-text.ts | 18 +- .../api/research-exec/capabilities/route.ts | 28 +- src/app/api/research-exec/profiles/route.ts | 39 +- src/app/api/research-exec/runs/route.ts | 21 +- src/app/api/scheduled-tasks/[taskId]/route.ts | 77 +- src/app/api/scheduled-tasks/route.ts | 60 +- src/app/api/settings/route.ts | 13 +- src/app/api/skills/[skillId]/export/route.ts | 28 +- src/app/api/skills/[skillId]/route.ts | 128 +- src/app/api/skills/claude-import/route.ts | 21 +- src/app/api/skills/clawhub-import/route.ts | 16 +- src/app/api/skills/import/preview/route.ts | 6 + src/app/api/skills/import/route.ts | 22 +- src/app/api/skills/route.ts | 52 +- src/app/api/terminal/exec/route.ts | 6 + src/app/api/weekly-report/route.ts | 5 + .../[workspaceId]/datasets/route.ts | 9 +- src/app/api/workspaces/[workspaceId]/route.ts | 43 +- .../workspaces/[workspaceId]/sync/route.ts | 28 +- src/app/api/workspaces/route.ts | 57 +- src/app/login/page.tsx | 95 + src/app/register/page.tsx | 105 + src/components/agent/agent-panel.tsx | 796 +++---- src/components/agent/buddy-avatar.tsx | 158 ++ src/components/agent/buddy-hatch-dialog.tsx | 158 ++ src/components/agent/cost-display.tsx | 96 + src/components/agent/memory-panel.tsx | 170 ++ src/components/agent/message-utils.ts | 11 + src/components/agent/slash-command.ts | 25 + src/components/agent/use-draggable-dialog.tsx | 133 ++ src/components/chat/chat-panel.tsx | 254 +-- .../conversation-compaction-dialogs.tsx | 284 +++ .../artifact-display-utils.test.ts | 18 + .../deep-research/artifact-display-utils.ts | 35 + .../artifact-renderer-primitives.tsx | 115 + .../artifact-renderer-registry.test.ts | 20 + .../artifact-renderer-registry.ts | 86 + .../artifact-renderers/analysis-renderers.tsx | 264 +++ .../artifact-renderers/evidence-renderers.tsx | 354 +++ .../artifact-renderers/memory-renderers.tsx | 163 ++ .../artifact-renderers/workflow-renderers.tsx | 426 ++++ .../deep-research/artifact-viewer.tsx | 1306 +---------- .../deep-research/checkpoint-review.tsx | 18 +- .../deep-research/deep-research-panel.tsx | 38 +- .../deep-research/final-report-view.tsx | 31 +- .../deep-research/research-chat.tsx | 23 +- .../deep-research/role-studio-panel.tsx | 13 +- src/components/layout/header.tsx | 2 + src/components/layout/user-menu.tsx | 61 + src/components/skills/skill-autocomplete.tsx | 104 +- src/instrumentation.ts | 6 +- src/lib/agent/buddy/companion.ts | 105 + src/lib/agent/buddy/storage.ts | 51 + src/lib/agent/buddy/types.ts | 99 + src/lib/agent/conversation-compaction.ts | 121 + src/lib/agent/cost-tracker.ts | 220 ++ src/lib/agent/kairos-memory.ts | 114 + src/lib/ai/agent-prompts.ts | 40 +- src/lib/ai/agent-tools.ts | 1 - src/lib/ai/models.ts | 1 + src/lib/ai/tools/search-tools-runtime.test.ts | 18 + src/lib/ai/tools/search-tools.ts | 4 +- src/lib/api-errors.test.ts | 72 + src/lib/api-errors.ts | 43 +- src/lib/auth/constants.ts | 17 + src/lib/auth/ownership.ts | 198 ++ src/lib/auth/password.ts | 24 + src/lib/auth/server.ts | 335 +++ .../feishu/agent-processor-runtime.test.ts | 18 + src/lib/bot/feishu/agent-processor.ts | 2 +- src/lib/bot/feishu/ws-client-runtime.test.ts | 123 + src/lib/bot/feishu/ws-client.test.ts | 20 +- src/lib/bot/feishu/ws-client.ts | 56 +- src/lib/db/index.ts | 60 +- src/lib/db/migrate.ts | 57 +- src/lib/db/schema.ts | 41 + src/lib/db/skills-insert.ts | 4 +- src/lib/deep-research/actors/main-brain.ts | 81 - src/lib/deep-research/actors/workers.ts | 56 - src/lib/deep-research/api-helpers.ts | 23 +- .../deep-research/artifact-references.test.ts | 73 + src/lib/deep-research/artifact-references.ts | 136 ++ .../deep-research/checkpoint-policy.test.ts | 49 + src/lib/deep-research/checkpoint-policy.ts | 28 + src/lib/deep-research/checkpoint-runtime.ts | 254 +++ src/lib/deep-research/config-types.ts | 98 + src/lib/deep-research/context-archive.test.ts | 147 ++ src/lib/deep-research/context-archive.ts | 736 ++++++ src/lib/deep-research/context-tag.ts | 34 + src/lib/deep-research/data-acquisition.ts | 267 --- src/lib/deep-research/dispatch-policy.test.ts | 76 + src/lib/deep-research/dispatch-policy.ts | 202 ++ src/lib/deep-research/event-store.ts | 9 +- src/lib/deep-research/exec-config.ts | 112 - src/lib/deep-research/exec-dataset-manager.ts | 285 --- src/lib/deep-research/exec-job-submitter.ts | 598 ----- src/lib/deep-research/exec-manifest.ts | 189 -- src/lib/deep-research/exec-pipeline.ts | 640 ------ .../deep-research/exec-preprocess-runner.ts | 455 ---- src/lib/deep-research/exec-readiness.ts | 272 --- src/lib/deep-research/execution-adapters.ts | 263 --- src/lib/deep-research/execution-planner.ts | 857 ------- .../deep-research/execution-round-manager.ts | 565 ----- src/lib/deep-research/execution-validator.ts | 338 --- src/lib/deep-research/experiment-analysis.ts | 358 --- .../deep-research/final-report-prompt.test.ts | 534 +++++ .../final-report-retry-policy.test.ts | 108 + .../final-report-retry-policy.ts | 92 + src/lib/deep-research/final-report-runtime.ts | 656 ++++++ src/lib/deep-research/final-report.test.ts | 34 +- src/lib/deep-research/final-report.ts | 93 + src/lib/deep-research/language-state.ts | 29 + src/lib/deep-research/model-overrides.test.ts | 38 + src/lib/deep-research/model-overrides.ts | 107 + src/lib/deep-research/model-router.test.ts | 76 + src/lib/deep-research/model-router.ts | 61 +- src/lib/deep-research/node-executor.ts | 89 +- .../deep-research/orchestrator-checkpoint.ts | 278 +++ .../orchestrator-confirmation.ts | 306 +++ src/lib/deep-research/orchestrator-runtime.ts | 582 +++++ src/lib/deep-research/orchestrator.ts | 1600 +------------ src/lib/deep-research/preprocessing.ts | 350 --- .../prompt-builders/checkpoint-prompt.ts | 238 ++ .../prompt-builders/final-report-prompt.ts | 1330 +++++++++++ .../prompt-builders/main-brain-prompt.ts | 192 ++ .../prompt-builders/review-prompt.ts | 57 + .../prompt-builders/worker-prompts.ts | 279 +++ src/lib/deep-research/prompts.ts | 788 +------ src/lib/deep-research/record-types.ts | 107 + src/lib/deep-research/refresh-policy.test.ts | 35 + src/lib/deep-research/refresh-policy.ts | 56 + src/lib/deep-research/remote-executor.ts | 564 ----- src/lib/deep-research/researcher-runtime.ts | 14 +- src/lib/deep-research/review-assessment.ts | 126 - src/lib/deep-research/session-guards.ts | 83 + src/lib/deep-research/skill-library.ts | 239 -- src/lib/deep-research/slurm-launcher.ts | 186 -- src/lib/deep-research/stale-detector.ts | 82 - src/lib/deep-research/status-types.ts | 155 ++ src/lib/deep-research/structured-types.ts | 108 + src/lib/deep-research/summary-packets.test.ts | 159 ++ src/lib/deep-research/summary-packets.ts | 450 ++++ src/lib/deep-research/synthesizer.ts | 384 ---- src/lib/deep-research/types.ts | 2020 +---------------- src/lib/deep-research/worker-aggregator.ts | 195 -- src/lib/deep-research/workflow-policy.ts | 1 + src/lib/deep-research/workflow-types.ts | 461 ++++ src/lib/dev/project-filesystem.test.ts | 114 + src/lib/dev/project-filesystem.ts | 199 ++ src/lib/env-file.test.ts | 10 + src/lib/env-file.ts | 18 + src/lib/fetcher.ts | 4 +- src/lib/files/text-extractor-runtime.test.ts | 19 + src/lib/files/text-extractor.ts | 2 +- src/lib/hooks/use-auth.ts | 17 + src/lib/hooks/use-cost-tracking.ts | 78 + src/lib/hooks/use-deep-research.ts | 48 +- src/lib/hooks/use-model-selection.ts | 191 ++ src/lib/paper-discussion/prompts.ts | 9 +- src/lib/paper-discussion/roles.ts | 23 +- src/lib/rag/vector-store.ts | 15 +- src/lib/research-ideation/prompts.ts | 11 +- src/lib/research-ideation/roles.ts | 25 +- src/types/auth.ts | 10 + tsconfig.json | 14 +- 271 files changed, 19763 insertions(+), 15279 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 .dev.pid create mode 100644 AGENTS.md create mode 100644 docs/_static/images/development/workflow-overview-en.png create mode 100644 docs/_static/images/development/workflow-overview-zh.png create mode 100644 docs/_templates/components/foot-copyright.html create mode 100644 docs/development/agent-development.md create mode 100644 docs/development/collaboration.md create mode 100644 docs/development/repository-guidelines.md create mode 100644 docs/locales/zh_CN/LC_MESSAGES/development/agent-development.po create mode 100644 docs/locales/zh_CN/LC_MESSAGES/development/collaboration.po create mode 100644 docs/locales/zh_CN/LC_MESSAGES/development/repository-guidelines.po create mode 100644 drizzle/0016_auth_users.sql create mode 100644 logs/dev.log create mode 100644 middleware.ts create mode 100644 plugins/innoclaw-cli/.codex-plugin/plugin.json create mode 100644 plugins/innoclaw-cli/README.md create mode 100644 plugins/innoclaw-cli/agents/openai.yaml create mode 100644 plugins/innoclaw-cli/assets/innoclaw-cli-logo.svg create mode 100644 plugins/innoclaw-cli/assets/innoclaw-cli-small.svg create mode 100755 plugins/innoclaw-cli/scripts/innoclaw-cli.mjs create mode 100644 plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md create mode 100644 src/app/admin/users/page.tsx create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/agent/buddy/route.ts create mode 100644 src/app/api/agent/memory/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/me/route.ts create mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/paper-study/extract-paper-content-runtime.test.ts create mode 100644 src/app/api/paper-study/extract-paper-text-runtime.test.ts create mode 100644 src/app/login/page.tsx create mode 100644 src/app/register/page.tsx create mode 100644 src/components/agent/buddy-avatar.tsx create mode 100644 src/components/agent/buddy-hatch-dialog.tsx create mode 100644 src/components/agent/cost-display.tsx create mode 100644 src/components/agent/memory-panel.tsx create mode 100644 src/components/agent/message-utils.ts create mode 100644 src/components/agent/use-draggable-dialog.tsx create mode 100644 src/components/conversation/conversation-compaction-dialogs.tsx create mode 100644 src/components/deep-research/artifact-display-utils.test.ts create mode 100644 src/components/deep-research/artifact-display-utils.ts create mode 100644 src/components/deep-research/artifact-renderer-primitives.tsx create mode 100644 src/components/deep-research/artifact-renderer-registry.test.ts create mode 100644 src/components/deep-research/artifact-renderer-registry.ts create mode 100644 src/components/deep-research/artifact-renderers/analysis-renderers.tsx create mode 100644 src/components/deep-research/artifact-renderers/evidence-renderers.tsx create mode 100644 src/components/deep-research/artifact-renderers/memory-renderers.tsx create mode 100644 src/components/deep-research/artifact-renderers/workflow-renderers.tsx create mode 100644 src/components/layout/user-menu.tsx create mode 100644 src/lib/agent/buddy/companion.ts create mode 100644 src/lib/agent/buddy/storage.ts create mode 100644 src/lib/agent/buddy/types.ts create mode 100644 src/lib/agent/conversation-compaction.ts create mode 100644 src/lib/agent/cost-tracker.ts create mode 100644 src/lib/agent/kairos-memory.ts delete mode 100644 src/lib/ai/agent-tools.ts create mode 100644 src/lib/ai/tools/search-tools-runtime.test.ts create mode 100644 src/lib/api-errors.test.ts create mode 100644 src/lib/auth/constants.ts create mode 100644 src/lib/auth/ownership.ts create mode 100644 src/lib/auth/password.ts create mode 100644 src/lib/auth/server.ts create mode 100644 src/lib/bot/feishu/agent-processor-runtime.test.ts create mode 100644 src/lib/bot/feishu/ws-client-runtime.test.ts delete mode 100644 src/lib/deep-research/actors/main-brain.ts delete mode 100644 src/lib/deep-research/actors/workers.ts create mode 100644 src/lib/deep-research/artifact-references.test.ts create mode 100644 src/lib/deep-research/artifact-references.ts create mode 100644 src/lib/deep-research/checkpoint-policy.test.ts create mode 100644 src/lib/deep-research/checkpoint-policy.ts create mode 100644 src/lib/deep-research/checkpoint-runtime.ts create mode 100644 src/lib/deep-research/config-types.ts create mode 100644 src/lib/deep-research/context-archive.test.ts create mode 100644 src/lib/deep-research/context-archive.ts create mode 100644 src/lib/deep-research/context-tag.ts delete mode 100644 src/lib/deep-research/data-acquisition.ts create mode 100644 src/lib/deep-research/dispatch-policy.test.ts create mode 100644 src/lib/deep-research/dispatch-policy.ts delete mode 100644 src/lib/deep-research/exec-config.ts delete mode 100644 src/lib/deep-research/exec-dataset-manager.ts delete mode 100644 src/lib/deep-research/exec-job-submitter.ts delete mode 100644 src/lib/deep-research/exec-manifest.ts delete mode 100644 src/lib/deep-research/exec-pipeline.ts delete mode 100644 src/lib/deep-research/exec-preprocess-runner.ts delete mode 100644 src/lib/deep-research/exec-readiness.ts delete mode 100644 src/lib/deep-research/execution-adapters.ts delete mode 100644 src/lib/deep-research/execution-planner.ts delete mode 100644 src/lib/deep-research/execution-round-manager.ts delete mode 100644 src/lib/deep-research/execution-validator.ts delete mode 100644 src/lib/deep-research/experiment-analysis.ts create mode 100644 src/lib/deep-research/final-report-prompt.test.ts create mode 100644 src/lib/deep-research/final-report-retry-policy.test.ts create mode 100644 src/lib/deep-research/final-report-retry-policy.ts create mode 100644 src/lib/deep-research/final-report-runtime.ts create mode 100644 src/lib/deep-research/language-state.ts create mode 100644 src/lib/deep-research/model-overrides.test.ts create mode 100644 src/lib/deep-research/model-overrides.ts create mode 100644 src/lib/deep-research/model-router.test.ts create mode 100644 src/lib/deep-research/orchestrator-checkpoint.ts create mode 100644 src/lib/deep-research/orchestrator-confirmation.ts create mode 100644 src/lib/deep-research/orchestrator-runtime.ts delete mode 100644 src/lib/deep-research/preprocessing.ts create mode 100644 src/lib/deep-research/prompt-builders/checkpoint-prompt.ts create mode 100644 src/lib/deep-research/prompt-builders/final-report-prompt.ts create mode 100644 src/lib/deep-research/prompt-builders/main-brain-prompt.ts create mode 100644 src/lib/deep-research/prompt-builders/review-prompt.ts create mode 100644 src/lib/deep-research/prompt-builders/worker-prompts.ts create mode 100644 src/lib/deep-research/record-types.ts create mode 100644 src/lib/deep-research/refresh-policy.test.ts create mode 100644 src/lib/deep-research/refresh-policy.ts delete mode 100644 src/lib/deep-research/remote-executor.ts delete mode 100644 src/lib/deep-research/review-assessment.ts create mode 100644 src/lib/deep-research/session-guards.ts delete mode 100644 src/lib/deep-research/skill-library.ts delete mode 100644 src/lib/deep-research/slurm-launcher.ts delete mode 100644 src/lib/deep-research/stale-detector.ts create mode 100644 src/lib/deep-research/status-types.ts create mode 100644 src/lib/deep-research/structured-types.ts create mode 100644 src/lib/deep-research/summary-packets.test.ts create mode 100644 src/lib/deep-research/summary-packets.ts delete mode 100644 src/lib/deep-research/synthesizer.ts delete mode 100644 src/lib/deep-research/worker-aggregator.ts create mode 100644 src/lib/deep-research/workflow-types.ts create mode 100644 src/lib/dev/project-filesystem.test.ts create mode 100644 src/lib/dev/project-filesystem.ts create mode 100644 src/lib/files/text-extractor-runtime.test.ts create mode 100644 src/lib/hooks/use-auth.ts create mode 100644 src/lib/hooks/use-cost-tracking.ts create mode 100644 src/lib/hooks/use-model-selection.ts create mode 100644 src/types/auth.ts diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..b572caef --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "innoclaw-local", + "interface": { + "displayName": "InnoClaw Local Plugins" + }, + "plugins": [ + { + "name": "innoclaw-cli", + "source": { + "source": "local", + "path": "./plugins/innoclaw-cli" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Research" + } + ] +} diff --git a/.claude/skills/paper-discussion-full/SKILL.md b/.claude/skills/paper-discussion-full/SKILL.md index 3c250d59..1517b4e4 100644 --- a/.claude/skills/paper-discussion-full/SKILL.md +++ b/.claude/skills/paper-discussion-full/SKILL.md @@ -46,11 +46,13 @@ Produce a comprehensive, evidence-grounded discussion report for one paper by em - State the core claim, method, setup, and reported results. - Separate explicit evidence from inference. +- When referencing external work for comparison, cite it using `searchArticles` results. ### Critical Analysis - Challenge overclaims, weak baselines, missing ablations, and threats to validity. - Mark issue severity as `Critical`, `Moderate`, or `Minor`. +- When identifying missing baselines or comparing to external methods, cite specific papers found via `searchArticles`. ### Reproducibility Check @@ -80,9 +82,38 @@ End with: `Overall take: ...` +## 8. References + ## Quality Rules -- Do not fabricate details not present in the paper context. +- Do not fabricate details not present in the paper context. All external references must come from `searchArticles` results, never from model memory. - Keep criticism specific and technically grounded. - Preserve nuance rather than collapsing everything into a single score. - If paper text cannot be retrieved, state that the report is based on limited context. + +## Citation Policy + +- Any claim about related work, competing methods, or external benchmarks MUST include a citation retrieved via `searchArticles`. Do NOT cite from memory. +- When the discussion references methods or results not in the seed paper, call `searchArticles` first and cite what is returned. +- If no supporting reference is found, mark the claim: **[Unverified — no supporting reference found via search]**. +- The target paper itself must be cited formally at the top of the report. + +### Inline Citation & Reference Format + +Use numbered inline citations in the text body (e.g., `[1]`, `[2]`) and collect full references in the `## 8. References` section. All references MUST be rendered in markdown so they are clickable and well-formatted: + +**Inline example:** +> 该方法在 ImageNet 上超越了 ViT **[1]**,但在小数据集上的泛化能力受到质疑 **[2]**。 + +**References section example:** + +``` +## 8. References + +1. **Dosovitskiy, A. et al.** (2021). *An Image is Worth 16x16 Words: Transformers for Image Recognition at Scale.* ICLR 2021. [arXiv:2010.11929](https://arxiv.org/abs/2010.11929) +2. **Liu, Z. et al.** (2021). *Swin Transformer: Hierarchical Vision Transformer using Shifted Windows.* ICCV 2021. [DOI:10.1109/ICCV48922.2021.00986](https://doi.org/10.1109/ICCV48922.2021.00986) +``` + +- Each reference line MUST include: **Author(s)** (Year). *Title.* Venue/Journal. Linked DOI or URL when available from `searchArticles`. +- Use markdown bold for authors, italic for title, and `[text](url)` for clickable DOI/URL links. +- Number references sequentially as they first appear in the text. diff --git a/.claude/skills/paper-discussion/SKILL.md b/.claude/skills/paper-discussion/SKILL.md index 8c4eecf5..73492771 100644 --- a/.claude/skills/paper-discussion/SKILL.md +++ b/.claude/skills/paper-discussion/SKILL.md @@ -22,7 +22,7 @@ Use this skill for interactive, grounded question answering about a single resea 1. Identify the target paper from the user's request. 2. If the paper is not identifiable from the current context, ask for the paper title, URL, or PDF link before continuing. 3. If the question requires more than title or abstract knowledge, use `readPaper` proactively to ground the discussion in the paper text. -4. Use `searchArticles` only when the user asks for related work, comparisons, neighboring methods, or follow-up reading. +4. Use `searchArticles` when the user asks for related work, comparisons, neighboring methods, or follow-up reading, AND whenever you would otherwise cite an external paper from memory. 5. Answer the user's actual question directly instead of dumping a generic summary. ## Discussion Priorities @@ -46,7 +46,34 @@ Use this skill for interactive, grounded question answering about a single resea ## Quality Rules -- Do not invent citations, baselines, implementation details, or results. +- Do not invent citations, baselines, implementation details, or results. Any external citation must be retrieved via `searchArticles` — never recalled from memory. - If the abstract is insufficient, say so and read the paper before making specific claims. - Do not overstate novelty or significance. - If the user asks for comparison, name the comparison axis explicitly: method, benchmark, claim, reproducibility, or application scope. + +## Citation Policy + +- When citing any paper other than the target paper, the citation MUST come from a `searchArticles` call, not model memory. +- If the user asks about related work or comparisons, call `searchArticles` before answering and cite returned results. +- If no reference is found, state: **[Unverified — no supporting reference found via search]** rather than citing from memory. +- When listing references in a structured answer, collect them in a **References** section at the end. + +### Inline Citation & Reference Format + +Use numbered inline citations in the text body (e.g., `[1]`, `[2]`) and collect full references at the end. All references MUST be rendered in markdown so they are clickable and well-formatted: + +**Inline example:** +> Transformer 架构已被广泛应用于蛋白质结构预测 **[1]**,并在 CASP14 中取得突破性成果 **[2]**。 + +**References section example:** + +``` +## References + +1. **Jumper, J. et al.** (2021). *Highly accurate protein structure prediction with AlphaFold.* Nature, 596, 583–589. [DOI:10.1038/s41586-021-03819-2](https://doi.org/10.1038/s41586-021-03819-2) +2. **Baek, M. et al.** (2021). *Accurate prediction of protein structures and interactions using a three-track neural network.* Science, 373(6557), 871–876. [DOI:10.1126/science.abj8754](https://doi.org/10.1126/science.abj8754) +``` + +- Each reference line MUST include: **Author(s)** (Year). *Title.* Venue/Journal. Linked DOI or URL when available from `searchArticles`. +- Use markdown bold for authors, italic for title, and `[text](url)` for clickable DOI/URL links. +- Number references sequentially as they first appear in the text. diff --git a/.claude/skills/research-ideation-full/SKILL.md b/.claude/skills/research-ideation-full/SKILL.md index 7b64fdde..5a34f18d 100644 --- a/.claude/skills/research-ideation-full/SKILL.md +++ b/.claude/skills/research-ideation-full/SKILL.md @@ -39,6 +39,7 @@ Turn one seed paper into a structured research ideation report by emulating the - novelty - connection to the seed paper - estimated impact + - supporting references (at least 1 per hypothesis, retrieved via `searchArticles`) ### Feasibility Review @@ -65,6 +66,7 @@ Turn one seed paper into a structured research ideation report by emulating the - expected outcomes - minimum viable experiment - timeline + - key references (cite the source for each baseline method and any adopted protocol, retrieved via `searchArticles`) ### Review @@ -83,10 +85,39 @@ Use this exact structure: ## 4. Review Findings ## 5. Recommended Actions ## 6. Overall Assessment +## 7. References ## Quality Rules -- Ground every idea in the seed paper or clearly labeled external evidence. +- Ground every idea in the seed paper or cited external evidence retrieved via `searchArticles`. Model-memory citations are forbidden. - Mark speculation explicitly when it goes beyond the paper. - Prefer testable and executable ideas over vague ambition. - If paper text is unavailable, say that the ideation is based on limited context. + +## Citation Policy + +- Every hypothesis rationale, related-work claim, baseline reference, and methodology mention MUST include a citation retrieved via `searchArticles`. Do NOT cite from memory. +- Use `searchArticles` proactively at the start of each hypothesis to locate supporting and contrasting literature before writing the rationale. +- If `searchArticles` returns no relevant result for a claim, mark the claim explicitly as: **[Unverified — no supporting reference found via search]**. +- Do not silently drop unreferenced claims; either find a reference or flag it. +- The seed paper itself must also be cited formally using the same format. + +### Inline Citation & Reference Format + +Use numbered inline citations in the text body (e.g., `[1]`, `[2]`) and collect full references in the `## 7. References` section. All references MUST be rendered in markdown so they are clickable and well-formatted: + +**Inline example:** +> 近期研究表明,基于扩散模型的分子生成方法在 drug-likeness 指标上显著优于传统 VAE 方法 **[1]**,同时在合成可达性方面也有改善 **[2]**。 + +**References section example:** + +``` +## 7. References + +1. **Xu, M. et al.** (2022). *GeoDiff: A Geometric Diffusion Model for Molecular Conformation Generation.* ICLR 2022. [arXiv:2203.02923](https://arxiv.org/abs/2203.02923) +2. **Hoogeboom, E. et al.** (2022). *Equivariant Diffusion for Molecule Generation in 3D.* ICML 2022. [arXiv:2203.17003](https://arxiv.org/abs/2203.17003) +``` + +- Each reference line MUST include: **Author(s)** (Year). *Title.* Venue/Journal. Linked DOI or URL when available from `searchArticles`. +- Use markdown bold for authors, italic for title, and `[text](url)` for clickable DOI/URL links. +- Number references sequentially as they first appear in the text. diff --git a/.dev.pid b/.dev.pid new file mode 100644 index 00000000..1cee4c1d --- /dev/null +++ b/.dev.pid @@ -0,0 +1 @@ +6148 diff --git a/.env.example b/.env.example index de163508..985bcaf4 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,14 @@ WORKSPACE_ROOTS=D:/Data/research,D:/Data/projects # Set this to a LOCAL filesystem path if the project is on a network/shared filesystem (NFS, CIFS, etc.) # DATABASE_URL=/tmp/innoclaw/innoclaw.db +# Secret used to sign local authentication session cookies. +# Set a long random value in production, for example: openssl rand -base64 32 +# AUTH_SECRET=replace-with-a-long-random-secret + # Next.js build directory (optional, defaults to .next in project root) -# Set this to a LOCAL filesystem path to avoid Turbopack cache errors on network/shared filesystems -# NEXT_BUILD_DIR=/tmp/innoclaw-next +# On Next.js 16 / Turbopack this must stay INSIDE the project root (for example .next-local) +# InnoClaw auto-disables Next's dist-dir lock on lockless network filesystems, so this is optional +# NEXT_BUILD_DIR=.next-local # GitHub Personal Access Token (for cloning/pulling private repos) GITHUB_TOKEN=ghp_... @@ -36,6 +41,7 @@ ZHIPU_API_KEY=... # SHLAB_INTERN_S1_PRO_BASE_URL=https://your-host/v1 # SHLAB_INTERN_S1_BASE_URL=https://your-host/v1 # QWEN_QWEN3_235B_BASE_URL=https://your-host/v1 +# QWEN_QWEN3_5_122B_BASE_URL=https://your-host/v1 # QWEN_QWEN3_5_397B_BASE_URL=https://your-host/v1 # MOONSHOT_KIMI_K2_5_BASE_URL=https://your-host/v1 # DEEPSEEK_DEEPSEEK_V3_2_BASE_URL=https://your-host/v1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3859f585..c990eef8 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -69,7 +69,7 @@ jobs: - name: Upload artifact if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: docs/_build/html diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..923f6b7e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,217 @@ +# InnoClaw Development Guidelines + +This file is the repository-level source of truth for day-to-day development workflow. Supporting detail lives in `docs/development/`, while executable command truth lives in `package.json`, `docs/Makefile`, and GitHub Actions workflows. + +## Source Of Truth + +Use these files in this order when instructions conflict: + +1. `AGENTS.md` +2. `package.json` scripts and `docs/Makefile` +3. `.github/workflows/*.yml` +4. Supporting narrative docs in `docs/development/` + +## Supported Environment + +- Node.js `24+` (`package.json` is authoritative; CI runs on Node 24 and 25) +- `npm` for dependency management and scripts +- Python `3.10+` for Sphinx documentation work (`docs/requirements.txt` currently pins `sphinx==8.1.3`) +- SQLite/Drizzle for local database migrations + +## Repository Boundaries + +Treat these directories as product code and documentation: + +- `src/` application code +- `scripts/` repository scripts +- `drizzle/` database migrations +- `docs/` documentation sources +- `public/` shipped static assets +- `site/` docs homepage assets + +Treat these directories as scratch space, references, or local-only artifacts. They are not part of routine app validation and must not be imported into shipped code: + +- `reference/` +- `.worktrees/` +- `.superpowers/` +- `.venv-docs/` +- `batch-test-logs/` +- `test-results/` +- `tmp-playwright-review/` + +## First-Time Setup + +```bash +npm install +cp .env.example .env.local +mkdir -p data +npx drizzle-kit migrate +``` + +Then edit `.env.local` and set at least: + +- `WORKSPACE_ROOTS` +- the API keys required for the provider(s) you plan to test + +## Before You Start + +- Run `git status --short` before large edits so you know whether you are entering a shared or dirty worktree. +- Identify the shared contracts your change may touch before coding. Common examples: schema, env vars, route response shapes, tool names, persisted session keys, and contributor-facing commands. +- Read the nearest source-of-truth files first: the relevant `package.json` script, nearby tests, and the matching page under `docs/development/`. +- Decide up front which validation commands and documentation updates your change will require. Do not wait until review time to discover missing follow-through. + +## Daily Workflow + +Start the app with either: + +```bash +npm run dev +``` + +or the helper scripts: + +```bash +bash dev-start.sh +bash dev-status.sh +bash dev-stop.sh +``` + +Use `npx tsc --noEmit` for a fast type-only pass when you do not need a full production build. The authoritative production verification remains `npm run build`. + +## Validation Matrix + +Run these checks before opening or updating a PR: + +```bash +npm run lint +npm test +NEXT_TELEMETRY_DISABLED=1 npm run build +``` + +When documentation changes: + +```bash +cd docs +make html +make html-zh +``` + +If your system `python3` is older than 3.10, create a dedicated docs virtualenv first: + +```bash +python3.12 -m venv .venv-docs +.venv-docs/bin/python -m pip install -r docs/requirements.txt +``` + +When English docs change, refresh translations: + +```bash +cd docs +make update-po +``` + +## Database Migration Rules + +- Treat `src/lib/db/schema.ts` as the schema source of truth. +- When schema changes, generate and commit the matching migration under `drizzle/`. +- Apply migrations locally with `npx drizzle-kit migrate` before requesting review. +- Do not edit already-committed historical migrations unless you are intentionally fixing unreleased local work and understand the impact on existing databases. +- If a change touches seed/default skill data or migration helpers, review the affected files in `src/lib/db/` and verify the startup path that uses them. + +## API Route Rules + +- Keep `src/app/api/**/route.ts` focused on HTTP concerns: request parsing, auth/context checks, validation, status codes, and response shape. +- Move reusable or multi-step business logic into `src/lib/` or `src/core/`; avoid burying domain logic inside route handlers. +- Validate request params early and return explicit `4xx` responses for caller mistakes. +- For non-streaming JSON errors, prefer the shared helpers in `src/lib/api-errors.ts` when practical instead of duplicating response shapes. +- Add or update `route.test.ts` coverage when changing route behavior, validation, or error handling. + +## Documentation Change Matrix + +- If you change setup steps or local commands, update `AGENTS.md` and the relevant pages under `docs/development/`. +- If you add, rename, or remove environment variables, update `.env.example`, `docs/getting-started/environment-variables.md`, and any affected setup docs. +- If you change contributor workflow or validation expectations, update `CONTRIBUTING.md`, `docs/development/contributing.md`, and any README links that point contributors there. +- If you change architecture or directory boundaries, update `docs/development/project-structure.md`. +- If you change testing workflow, update `docs/development/testing.md`. +- If you change documentation workflow itself, update `docs/development/documentation.md`. + +## Collaboration Rules + +- Do not overwrite or revert unrelated local changes you did not make; treat a dirty worktree as shared state until you confirm otherwise. +- Keep changes scoped to one user-visible problem or one engineering concern. Split unrelated cleanup from behavior or contract changes. +- Re-read the latest file contents before editing areas that are actively changing or likely to be touched by others. +- When a change affects a shared contract, call it out explicitly in the PR or handoff summary. Shared contracts include schema, environment variables, route response shapes, tool names, and persisted localStorage/session keys. +- Include fresh verification evidence in your review or handoff summary instead of claiming success without command output. +- Prefer additive migration paths over silent rewrites when multiple developers or automation flows may depend on the current behavior. +- Prefer small, reviewable patches. If you intentionally leave follow-up work out of scope, say so directly instead of hiding it in TODO comments or broad summaries. + +## Review And Handoff + +When handing work to another developer, reviewer, or automation tool, include: + +- what changed and why it matters +- which shared contracts or risky files need extra attention +- the exact validation commands you ran and the result you observed +- any known follow-ups, rollout notes, or unresolved risks + +Use this structure when practical: + +```text +Summary: +Contracts: +Validation: +Follow-ups: +``` + +## Agent Development Rules + +- Keep prompts, provider wiring, tool registration, and orchestration logic out of UI components and route handlers whenever practical. +- Treat `src/lib/ai/tool-names.ts` and `src/lib/ai/tools/` as the central contract for tool availability and privilege boundaries. +- Default new agent capabilities to least privilege. High-risk tools should require explicit opt-in, matching the separation between `ALL_TOOLS` and `K8S_TOOLS`. +- Validate workspace paths, tool inputs, and execution context through shared helpers and typed context objects. Do not add raw filesystem or exec access that bypasses workspace validation. +- Keep streaming and resume behavior stable. If you change agent response streaming, review `src/lib/agent/agent-stream-manager.ts` and the corresponding UI flows together. +- Keep deep-research role definitions, doctrine loading, workflow policy, and UI assumptions synchronized across `src/lib/deep-research/`, `src/app/api/deep-research/`, and `src/components/deep-research/`. +- Any new tool, mode, provider, or role should ship with tests for its contract changes and with documentation updates where contributors need to understand the new behavior. +- High-risk execution paths, remote execution, and cluster operations must remain auditable and gated behind explicit configuration or approval boundaries. +- Prompt, doctrine, or role-text changes must live in code-backed prompt files or registries, not inline UI strings. Review dependent parsers, status names, and UI copy together. +- Tool additions or privilege-tier changes require allow/deny-path tests, stable naming, and docs updates if another contributor or operator needs to understand the capability. +- Session, streaming, or persistence changes must review the API route, runtime manager, and any localStorage or session-key consumers together so resume behavior does not silently regress. +- Prefer explicit failure states, logs, and surfaced status over silent fallbacks when changing agent orchestration or provider branching. + +## Change Expectations + +- Follow the existing feature-first structure in `src/components/` and domain structure in `src/lib/`. +- Keep route handlers in `src/app/api/` thin; put reusable logic in `src/lib/` or `src/core/`. +- Keep tests co-located as `*.test.ts` or `*.test.tsx`. +- Update docs when you change setup, environment variables, routes, workflows, or contributor-facing behavior. +- Do not commit secrets, `.env.local`, build output, or local-only scratch directories. + +## Branches, Commits, And PRs + +Preferred branch prefixes: + +- `feature/` +- `fix/` +- `docs/` +- `chore/` + +Use Conventional Commits: + +```text +(): +``` + +Examples: + +- `feat(agent): add runtime capability probe` +- `fix(workspace): guard invalid root path` +- `docs(dev): refresh contributor workflow` + +## Contributor Checklist + +Before requesting review: + +- Confirm the relevant validation commands pass locally +- Confirm docs are updated for developer-facing behavior changes +- Confirm tests cover new behavior or regressions where practical +- Confirm changes stay within repository boundaries and do not depend on local scratch content diff --git a/CLAUDE.md b/CLAUDE.md index b4c02028..e706238c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,3 @@ # Researcher Doctrine -This repository contains a research-oriented coding and automation system. When acting as the `Researcher` main-brain, load the doctrine below before planning, dispatching, or revising the workflow. - -@.claude/researcher/SOUL.md -@.claude/researcher/THINKING_MODES.md -@.claude/researcher/HANDOFF_TEMPLATES.md -@.claude/researcher/SKILLS.md +This repository contains a research-oriented coding and automation system. When acting as the `Researcher` main-brain, load the researcher skills from `.claude/skills/researcher-*` before planning, dispatching, or revising the workflow. diff --git a/Dockerfile b/Dockerfile index 462a7a80..2291b5fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,10 @@ COPY --from=deps /app/node_modules/drizzle-kit ./node_modules/drizzle-kit # ensure they exist (standalone may place them in nested paths) COPY --from=deps /app/node_modules/drizzle-orm ./node_modules/drizzle-orm COPY --from=deps /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 +# pdf-parse depends on @napi-rs/canvas at runtime for DOM geometry polyfills. +# Copy the full namespace so the platform-specific binary package from the Linux +# build stage is available in the production runner. +COPY --from=deps /app/node_modules/@napi-rs ./node_modules/@napi-rs # Copy entrypoint COPY scripts/docker-entrypoint.sh /docker-entrypoint.sh diff --git a/README.md b/README.md index 9ed65fb4..90e0098f 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ OPENAI_API_KEY=sk-... - `WORKSPACE_ROOTS` directories must already exist before startup - `npx drizzle-kit migrate` creates or upgrades the SQLite schema at `./data/innoclaw.db` -- If the repo lives on NFS/CIFS, set `DATABASE_URL` and `NEXT_BUILD_DIR` to local disk paths +- If the repo lives on NFS/CIFS or another mount without local file locking, InnoClaw now disables Next's dist-dir lock automatically so `npm run dev` can start. You should still set `DATABASE_URL` to a local disk path for SQLite. `NEXT_BUILD_DIR`, if used, must stay inside the repo (for example `.next-local`). **Upgrading:** @@ -134,11 +134,38 @@ Go from code inspection to job submission and result analysis. Review repositori +#### 2026-04-17 +- **InnoClaw CLI**: Run the app, manage workspaces, and create/run/export Deep Research sessions from the terminal +- **Deep Research Checkpoints**: Research now pauses at review points so you can continue, revise, branch, reject, or stop runs +- **Role Studio**: New Deep Research tab to inspect specialist roles and send targeted instructions to the Researcher or workers + + +#### 2026-04-12 +- **Docker Deployment Support**: Self-host InnoClaw with Docker and docker-compose, with guides for setup, volumes, and upgrades +- **200+ Built-in Skills**: Massive expansion of ready-to-use scientific skills across bioinformatics, chemistry, genomics, and physics +- **Skill Creator Framework**: New meta-skill for creating, evaluating, benchmarking, and validating custom skills + + + +
+Show earlier updates + +#### 2026-04-02 +- **Docker Deployment Support**: Added Dockerfile, docker-compose.yml, and full Docker deployment guide for self-hosted production setups +- **200+ New Built-in Skills**: Expanded skill library with bioinformatics, cheminformatics, genomics, physics, and drug discovery pipelines +- **Skill Creator Framework**: New meta-skill with evaluation, benchmarking, and validation tooling for building and testing custom skills + + + + #### 2026-04-01 - **Text-to-CAD Skill**: New agent skill that converts natural language descriptions into 3D CAD models (STL/STEP) using CadQuery, with automatic environment setup - **Workspace Image Picker**: New dialog UI in the agent panel for browsing and selecting images from the workspace to attach to conversations + + + #### 2026-03-31 - **Pasted Image Support**: Users can now paste images directly into the chat input for multimodal AI conversations - **Deep Research Role Studio**: New Role Studio panel lets users configure and manage custom researcher roles in the deep research workflow @@ -146,8 +173,8 @@ Go from code inspection to job submission and result analysis. Review repositori -
-Show earlier updates + + #### 2026-03-26 - **Dynamic Model Discovery**: Agent panel now auto-fetches available models from each configured AI provider, merging live results with built-in model lists @@ -157,32 +184,21 @@ Go from code inspection to job submission and result analysis. Review repositori -#### 2026-03-26 -- **Node.js Runtime Update**: InnoClaw now targets Node.js 24+ and is verified against both Node.js 24 LTS and the latest Node.js 25 current release. CI and local version hints have been updated accordingly. - - - - - -#### 2026-03-24 -- **Multimodal LLM Support**: Paper Study and agent workflows now support both standard LLMs and multimodal LLMs (mLLM), selectable per-context in settings and the model selector +#### 2026-03-26 +- **Node.js Runtime Update**: InnoClaw now targets Node.js 24+ and is verified against both Node.js 24 LTS and the latest Node.js 25 current release. CI and local version hints have been updated accordingly. -#### 2026-03-23 -- **GitHub Skills Import Preview**: New pre-import preview workflow lets users browse, review, and selectively import skills from GitHub repositories before committing changes -#### 2026-03-22 -- **Obsidian Note Export**: Generate structured, Obsidian-compatible paper notes with rich YAML frontmatter, figures, and wikilinks directly from the paper study panel -- **Per-Task Model Selector**: New model selector UI component lets users override the default AI model for individual paper study tasks (summary, roast, notes, etc.) -- **Note Discussion View**: New full-page discussion view for paper notes, enabling threaded AI-assisted conversations around generated note content +#### 2026-03-24 +- **Multimodal LLM Support**: Paper Study and agent workflows now support both standard LLMs and multimodal LLMs (mLLM), selectable per-context in settings and the model selector @@ -190,11 +206,9 @@ Go from code inspection to job submission and result analysis. Review repositori -#### 2026-03-21 -- **Remote HPC/SLURM Execution**: Deep research sessions can now run on remote clusters via SSH, supporting rjob, rlaunch, and SLURM schedulers with file staging and job lifecycle management -- **Kubernetes Cluster Config UI**: New settings panel for runtime configuration of K8s contexts, PVC bindings, and container images across multi-cluster deployments without restarting -- **Remote Profile Binding**: Deep research sessions can be bound to pre-configured SSH/remote compute profiles, enabling reproducible distributed research workflows +#### 2026-03-23 +- **GitHub Skills Import Preview**: New pre-import preview workflow lets users browse, review, and selectively import skills from GitHub repositories before committing changes @@ -202,19 +216,16 @@ Go from code inspection to job submission and result analysis. Review repositori -#### 2026-03-20 -- **Deep Research Module**: Full AI-driven scientific research pipeline with multi-phase orchestration, reviewer deliberation, execution planning, and workflow graph UI -- **Execution Pipeline**: Automated experiment execution system with Slurm job submission, dataset management, preprocessing, and remote executor support +#### 2026-03-22 +- **Obsidian Note Export**: Generate structured, Obsidian-compatible paper notes with rich YAML frontmatter, figures, and wikilinks directly from the paper study panel +- **Per-Task Model Selector**: New model selector UI component lets users override the default AI model for individual paper study tasks (summary, roast, notes, etc.) +- **Note Discussion View**: New full-page discussion view for paper notes, enabling threaded AI-assisted conversations around generated note content -#### 2026-03-19 -- **ClawHub Skill Import**: New integration to import skills directly from ClawHub via a dedicated API endpoint and import dialog -- **Code Preview Panel**: New in-editor code preview component supporting syntax highlighting and save-status tracking -- **Paper Study Cache**: Persistent caching layer for paper study sessions, improving reload performance and state continuity @@ -295,12 +306,3 @@ Go from code inspection to job submission and result analysis. Review repositori - **Repository** — https://github.com/SpectrAI-Initiative/InnoClaw - **Docs** — https://SpectrAI-Initiative.github.io/InnoClaw/ -## Star History - - - - - - Star History Chart - - diff --git a/docs/README_CN.md b/docs/README_CN.md index 80c9e3b7..e67fdd17 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # InnoClaw

@@ -45,80 +49,81 @@ InnoClaw 将服务器文件夹变成 AI 原生工作空间,用于基于文档 -#### 2026-04-01 -- **自然语言转 CAD 技能**: 新增 Agent 技能,通过 CadQuery 将自然语言描述转换为 3D CAD 模型(STL/STEP),并自动配置运行环境 -- **工作区图片选择器**: Agent 面板新增对话框 UI,支持浏览并选取工作区中的图片附加到对话 +#### 2026-04-17 +- **InnoClaw 命令行工具**: 可在终端运行应用、管理工作区,并创建/运行/导出 Deep Research 会话 +- **Deep Research 检查点**: 研究流程现可在关键节点暂停,支持继续、修订、分支、拒绝或停止 +- **角色工作室**: 新增 Deep Research 角色页,可查看专家角色并向 Researcher 或各工作角色定向下达指令 -#### 2026-03-31 -- **粘贴图片支持**: 用户现在可以直接将图片粘贴到聊天输入框中,进行多模态 AI 对话 -- **深度研究角色工作室**: 新增角色工作室面板,支持在深度研究工作流中配置和管理自定义研究员角色 -- **论文搜索源扩展**: Paper Study 新增支持 BioRxiv、PubMed 和 PubChem 作为可检索的论文来源 +#### 2026-04-12 +- **Docker 部署支持**: 现在可通过 Docker 和 docker-compose 自托管 InnoClaw,并提供部署、挂载与升级指南 +- **200+ 内置技能**: 大幅扩展可直接使用的科研技能,覆盖生物信息、化学、基因组学与物理等领域 +- **技能创建框架**: 新增元技能,可用于创建、自测、基准评估和校验自定义技能

显示更早的更新 -#### 2026-03-26 -- **动态模型发现**: Agent 面板现可自动从每个已配置的 AI 提供商获取可用模型列表,并与内置模型合并展示 -- **单模型 Base URL 路由**: 国内 AI 提供商(shlab、qwen、moonshot、deepseek、minimax、zhipu)支持通过 `__BASE_URL` 环境变量为单个模型配置独立接入地址 -- **运行时工具调用开关**: 可通过 `_TOOLS_ENABLED=true/false` 环境变量按提供商动态开关工具调用能力,无需修改代码 +#### 2026-04-02 +- **Docker 部署支持**: 新增 Dockerfile、docker-compose.yml 及完整 Docker 部署指南,支持自托管生产环境部署 +- **200+ 内置技能扩充**: 大幅扩展技能库,涵盖生物信息学、化学信息学、基因组学、物理学及药物发现流程 +- **技能创建框架**: 新增元技能,提供评估、基准测试与验证工具,用于构建和测试自定义技能 -#### 2026-03-26 -- **Node.js 运行时更新**: InnoClaw 现以 Node.js 24+ 为目标运行时,并已验证兼容 Node.js 24 LTS 与最新的 Node.js 25 Current 版本。CI 与本地版本提示也已同步更新。 +#### 2026-04-01 +- **自然语言转 CAD 技能**: 新增 Agent 技能,通过 CadQuery 将自然语言描述转换为 3D CAD 模型(STL/STEP),并自动配置运行环境 +- **工作区图片选择器**: Agent 面板新增对话框 UI,支持浏览并选取工作区中的图片附加到对话 -#### 2026-03-24 -- **多模态大模型支持**: 论文研究与智能体工作流现支持标准 LLM 与多模态 LLM(mLLM),可在设置页面和模型选择器中按上下文切换 +#### 2026-03-31 +- **粘贴图片支持**: 用户现在可以直接将图片粘贴到聊天输入框中,进行多模态 AI 对话 +- **深度研究角色工作室**: 新增角色工作室面板,支持在深度研究工作流中配置和管理自定义研究员角色 +- **论文搜索源扩展**: Paper Study 新增支持 BioRxiv、PubMed 和 PubChem 作为可检索的论文来源 -#### 2026-03-23 -- **GitHub 技能导入预览**: 新增导入前预览流程,支持浏览 GitHub 仓库中的技能列表并按需选择性导入 +#### 2026-03-26 +- **动态模型发现**: Agent 面板现可自动从每个已配置的 AI 提供商获取可用模型列表,并与内置模型合并展示 +- **单模型 Base URL 路由**: 国内 AI 提供商(shlab、qwen、moonshot、deepseek、minimax、zhipu)支持通过 `__BASE_URL` 环境变量为单个模型配置独立接入地址 +- **运行时工具调用开关**: 可通过 `_TOOLS_ENABLED=true/false` 环境变量按提供商动态开关工具调用能力,无需修改代码 -#### 2026-03-22 -- **Obsidian 笔记导出**: 在论文学习面板中直接生成带有丰富 YAML 元数据、图片和 Wikilink 的结构化 Obsidian 兼容笔记 -- **按任务选择模型**: 新增模型选择器 UI 组件,支持为各个论文学习任务(摘要、评审、笔记等)单独覆盖默认 AI 模型 -- **笔记讨论视图**: 新增论文笔记全页讨论视图,支持围绕生成笔记内容进行多轮 AI 辅助对话 +#### 2026-03-26 +- **Node.js 运行时更新**: InnoClaw 现以 Node.js 24+ 为目标运行时,并已验证兼容 Node.js 24 LTS 与最新的 Node.js 25 Current 版本。CI 与本地版本提示也已同步更新。 -#### 2026-03-21 -- **远程 HPC/SLURM 执行**: 深度研究任务现可通过 SSH 在远程集群上运行,支持 rjob、rlaunch 和 SLURM 调度器,具备文件传输与任务生命周期管理能力 -- **Kubernetes 集群配置界面**: 新增设置面板,支持在不重启服务的情况下动态配置 K8s 上下文、PVC 绑定及容器镜像,适用于多集群部署 -- **远程执行档案绑定**: 深度研究会话可绑定预配置的 SSH/远程计算档案,实现可复现的分布式研究工作流 +#### 2026-03-24 +- **多模态大模型支持**: 论文研究与智能体工作流现支持标准 LLM 与多模态 LLM(mLLM),可在设置页面和模型选择器中按上下文切换 -#### 2026-03-20 -- **深度研究模块**: 完整的 AI 驱动科学研究流程,支持多阶段编排、评审员辩论、执行规划与工作流可视化界面 -- **执行流水线**: 自动化实验执行系统,支持 Slurm 作业提交、数据集管理、预处理与远程执行器 +#### 2026-03-23 +- **GitHub 技能导入预览**: 新增导入前预览流程,支持浏览 GitHub 仓库中的技能列表并按需选择性导入 @@ -127,11 +132,11 @@ InnoClaw 将服务器文件夹变成 AI 原生工作空间,用于基于文档 -#### 2026-03-19 -- **ClawHub 技能导入**: 新增从 ClawHub 直接导入技能的集成功能,包含专用 API 端点和导入对话框 -- **代码预览面板**: 新增编辑器内代码预览组件,支持语法高亮及保存状态追踪 -- **论文学习缓存**: 为论文学习会话新增持久化缓存层,提升重载性能与状态连续性 +#### 2026-03-22 +- **Obsidian 笔记导出**: 在论文学习面板中直接生成带有丰富 YAML 元数据、图片和 Wikilink 的结构化 Obsidian 兼容笔记 +- **按任务选择模型**: 新增模型选择器 UI 组件,支持为各个论文学习任务(摘要、评审、笔记等)单独覆盖默认 AI 模型 +- **笔记讨论视图**: 新增论文笔记全页讨论视图,支持围绕生成笔记内容进行多轮 AI 辅助对话 @@ -211,7 +216,7 @@ OPENAI_API_KEY=sk-... - `WORKSPACE_ROOTS` 中的目录需要提前创建,应用不会自动创建 - `npx drizzle-kit migrate` 会初始化或升级默认位于 `./data/innoclaw.db` 的 SQLite 数据库 -- 如果项目放在 NFS/CIFS 等网络文件系统上,请把 `DATABASE_URL` 和 `NEXT_BUILD_DIR` 指到本地磁盘路径 +- 如果项目放在 NFS/CIFS 或其他禁用了本地文件锁的挂载上,InnoClaw 现在会自动关闭 Next 的 dist-dir 锁,让 `npm run dev` 能启动;但 SQLite 仍然建议把 `DATABASE_URL` 指到本地磁盘。`NEXT_BUILD_DIR` 如果要设置,必须仍然位于仓库内,例如 `.next-local`。 ### 2. 启动 diff --git a/docs/README_DE.md b/docs/README_DE.md index a0bcb134..c535bf25 100644 --- a/docs/README_DE.md +++ b/docs/README_DE.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # InnoClaw

@@ -47,73 +51,71 @@ Es richtet sich an Forschende, Entwickler, Labore und Self-Hoster, die mehr als -#### 2026-04-01 -- **Text-zu-CAD-Faehigkeit**: Neue Agenten-Faehigkeit, die natuerlichsprachige Beschreibungen mit CadQuery in 3D-CAD-Modelle (STL/STEP) umwandelt, mit automatischer Einrichtung der Umgebung -- **Arbeitsbereich-Bildauswahl**: Neues Dialog-UI im Agenten-Panel zum Durchsuchen und Auswaehlen von Bildern aus dem Arbeitsbereich zum Anhaengen an Konversationen +#### 2026-04-17 +- **InnoClaw CLI**: App im Terminal ausfuehren, Arbeitsbereiche verwalten und Deep-Research-Sitzungen erstellen, starten oder exportieren +- **Deep-Research-Pruefpunkte**: Die Recherche pausiert jetzt an Pruefpunkten, damit du Laeufe fortsetzen, ueberarbeiten, verzweigen, ablehnen oder stoppen kannst +- **Rollenstudio**: Neuer Deep-Research-Tab zum Pruefen spezialisierter Rollen und zum Senden gezielter Anweisungen an den Researcher oder Worker -#### 2026-03-31 -- **Eingefuegte Bilder unterstuetzt**: Benutzer koennen Bilder jetzt direkt in die Chat-Eingabe einfuegen fuer multimodale KI-Konversationen -- **Deep-Research-Rollenstudio**: Das neue Rollenstudio-Panel ermoeglicht das Konfigurieren und Verwalten benutzerdefinierter Forscherrollen im Deep-Research-Workflow -- **Erweiterte Quellen fuer die Artikelsuche**: BioRxiv, PubMed und PubChem wurden als durchsuchbare Artikelquellen in Paper Study hinzugefuegt +#### 2026-04-12 +- **Docker-Deployment-Support**: InnoClaw mit Docker und docker-compose selbst hosten, mit Anleitungen fuer Setup, Volumes und Upgrades +- **200+ integrierte Skills**: Deutliche Erweiterung sofort nutzbarer wissenschaftlicher Skills fuer Bioinformatik, Chemie, Genomik und Physik +- **Skill-Erstellungsframework**: Neues Meta-Skill zum Erstellen, Bewerten, Benchmarken und Validieren eigener Skills

Aeltere Updates anzeigen -#### 2026-03-26 -- **Dynamische Modellerkennung**: Das Agenten-Panel ruft verfuegbare Modelle automatisch von jedem konfigurierten KI-Anbieter ab und fuegt sie mit der eingebauten Liste zusammen -- **Modellspezifisches Base-URL-Routing**: Chinesische KI-Anbieter (shlab, qwen, moonshot, deepseek, minimax, zhipu) unterstuetzen `__BASE_URL`-Umgebungsvariablen fuer flexibles Endpunkt-Routing -- **Laufzeit-Umschalter fuer Tool-Aufruf**: Tool-Unterstuetzung kann per Anbieter ueber `_TOOLS_ENABLED=true/false` ohne Codeaenderungen aktiviert oder deaktiviert werden - +#### 2026-04-02 +- **Docker-Deployment-Unterstuetzung**: Dockerfile, docker-compose.yml und eine vollstaendige Docker-Deployment-Anleitung fuer selbst gehostete Produktionsumgebungen hinzugefuegt +- **200+ neue integrierte Skills**: Skill-Bibliothek erweitert um Bioinformatik, Chemoinformatik, Genomik, Physik und Drug-Discovery-Pipelines +- **Skill-Creator-Framework**: Neuer Meta-Skill mit Werkzeugen fuer Evaluierung, Benchmarking und Validierung zum Erstellen und Testen eigener Skills -#### 2026-03-24 -- **Multimodaler LLM-Support**: Papierrecherche und Agenten-Workflows unterstuetzen jetzt Standard-LLMs und multimodale LLMs (mLLM), kontextbezogen auswaehlbar in den Einstellungen und der Modellauswahl. - - +#### 2026-04-01 +- **Text-zu-CAD-Faehigkeit**: Neue Agenten-Faehigkeit, die natuerlichsprachige Beschreibungen mit CadQuery in 3D-CAD-Modelle (STL/STEP) umwandelt, mit automatischer Einrichtung der Umgebung +- **Arbeitsbereich-Bildauswahl**: Neues Dialog-UI im Agenten-Panel zum Durchsuchen und Auswaehlen von Bildern aus dem Arbeitsbereich zum Anhaengen an Konversationen -#### 2026-03-23 -- **GitHub-Faehigkeiten Import-Vorschau**: Neuer Vorschau-Workflow vor dem Import ermoeglicht das Durchsuchen, Pruefen und selektive Importieren von Faehigkeiten aus GitHub-Repositories +#### 2026-03-31 +- **Eingefuegte Bilder unterstuetzt**: Benutzer koennen Bilder jetzt direkt in die Chat-Eingabe einfuegen fuer multimodale KI-Konversationen +- **Deep-Research-Rollenstudio**: Das neue Rollenstudio-Panel ermoeglicht das Konfigurieren und Verwalten benutzerdefinierter Forscherrollen im Deep-Research-Workflow +- **Erweiterte Quellen fuer die Artikelsuche**: BioRxiv, PubMed und PubChem wurden als durchsuchbare Artikelquellen in Paper Study hinzugefuegt -#### 2026-03-22 -- **Obsidian-Notizexport**: Generieren Sie strukturierte, Obsidian-kompatible Notizen mit reichhaltigem YAML-Frontmatter, Abbildungen und Wikilinks direkt aus dem Paper-Studienpanel -- **Modellauswahl pro Aufgabe**: Eine neue Modellauswahl-UI-Komponente erlaubt es Nutzern, das Standard-KI-Modell fuer einzelne Paper-Studienaufgaben (Zusammenfassung, Kritik, Notizen usw.) zu ueberschreiben -- **Notiz-Diskussionsansicht**: Neue ganzseitige Diskussionsansicht fuer Paper-Notizen, die gebuendelte KI-gestuetzte Gespraeche rund um generierten Notizinhalt ermoeglichen +#### 2026-03-26 +- **Dynamische Modellerkennung**: Das Agenten-Panel ruft verfuegbare Modelle automatisch von jedem konfigurierten KI-Anbieter ab und fuegt sie mit der eingebauten Liste zusammen +- **Modellspezifisches Base-URL-Routing**: Chinesische KI-Anbieter (shlab, qwen, moonshot, deepseek, minimax, zhipu) unterstuetzen `__BASE_URL`-Umgebungsvariablen fuer flexibles Endpunkt-Routing +- **Laufzeit-Umschalter fuer Tool-Aufruf**: Tool-Unterstuetzung kann per Anbieter ueber `_TOOLS_ENABLED=true/false` ohne Codeaenderungen aktiviert oder deaktiviert werden -#### 2026-03-21 -- **Entfernte HPC/SLURM-Ausfuehrung**: Tiefe Recherche-Sitzungen koennen jetzt ueber SSH auf entfernten Clustern ausgefuehrt werden, mit Unterstuetzung fuer rjob, rlaunch und SLURM sowie Datei-Staging und Job-Lifecycle-Verwaltung -- **Kubernetes-Cluster-Konfigurationsoberflaeche**: Neues Einstellungspanel zur Laufzeitkonfiguration von K8s-Kontexten, PVC-Bindungen und Container-Images in Multi-Cluster-Umgebungen ohne Neustart -- **Entfernte Profil-Bindung**: Tiefe Recherche-Sitzungen koennen an vorkonfigurierte SSH/Remote-Rechenprofile gebunden werden, was reproduzierbare verteilte Forschungs-Workflows ermoeglicht +#### 2026-03-24 +- **Multimodaler LLM-Support**: Papierrecherche und Agenten-Workflows unterstuetzen jetzt Standard-LLMs und multimodale LLMs (mLLM), kontextbezogen auswaehlbar in den Einstellungen und der Modellauswahl. -#### 2026-03-20 -- **Tiefenforschungsmodul**: Vollstaendige KI-gesteuerte wissenschaftliche Forschungs-Pipeline mit Mehrphasen-Orchestrierung, Gutachter-Diskussion, Ausfuehrungsplanung und Workflow-Grafik-Oberflaeche -- **Ausfuehrungs-Pipeline**: Automatisiertes System zur Experimentausfuehrung mit Slurm-Job-Uebermittlung, Datensatzverwaltung, Vorverarbeitung und Unterstuetzung fuer entfernte Ausfuehrer +#### 2026-03-23 +- **GitHub-Faehigkeiten Import-Vorschau**: Neuer Vorschau-Workflow vor dem Import ermoeglicht das Durchsuchen, Pruefen und selektive Importieren von Faehigkeiten aus GitHub-Repositories @@ -122,11 +124,11 @@ Es richtet sich an Forschende, Entwickler, Labore und Self-Hoster, die mehr als -#### 2026-03-19 -- **ClawHub-Skill-Import**: Neue Integration zum direkten Importieren von Skills aus ClawHub ueber einen dedizierten API-Endpunkt und einen Import-Dialog -- **Code-Vorschaufenster**: Neue In-Editor-Komponente fuer die Code-Vorschau mit Syntaxhervorhebung und Verfolgung des Speicherstatus -- **Paper-Studie-Cache**: Persistente Caching-Schicht fuer Paper-Studien-Sitzungen zur Verbesserung der Neulade-Performance und Zustandskontinuitaet +#### 2026-03-22 +- **Obsidian-Notizexport**: Generieren Sie strukturierte, Obsidian-kompatible Notizen mit reichhaltigem YAML-Frontmatter, Abbildungen und Wikilinks direkt aus dem Paper-Studienpanel +- **Modellauswahl pro Aufgabe**: Eine neue Modellauswahl-UI-Komponente erlaubt es Nutzern, das Standard-KI-Modell fuer einzelne Paper-Studienaufgaben (Zusammenfassung, Kritik, Notizen usw.) zu ueberschreiben +- **Notiz-Diskussionsansicht**: Neue ganzseitige Diskussionsansicht fuer Paper-Notizen, die gebuendelte KI-gestuetzte Gespraeche rund um generierten Notizinhalt ermoeglichen @@ -137,9 +139,10 @@ Es richtet sich an Forschende, Entwickler, Labore und Self-Hoster, die mehr als -#### 2026-03-18 -- **Multimodale Bildanalyse fuer Papierauswertung**: PDF-Bilder werden jetzt waehrend Diskussions- und Forschungsideensitzungen visuell extrahiert und analysiert -- **Claude Code Skills-Integration**: Importieren Sie Skills direkt aus lokalen Ordnern oder Claude Code-Projekten ueber einen neuen dedizierten Import-Workflow +#### 2026-03-21 +- **Entfernte HPC/SLURM-Ausfuehrung**: Tiefe Recherche-Sitzungen koennen jetzt ueber SSH auf entfernten Clustern ausgefuehrt werden, mit Unterstuetzung fuer rjob, rlaunch und SLURM sowie Datei-Staging und Job-Lifecycle-Verwaltung +- **Kubernetes-Cluster-Konfigurationsoberflaeche**: Neues Einstellungspanel zur Laufzeitkonfiguration von K8s-Kontexten, PVC-Bindungen und Container-Images in Multi-Cluster-Umgebungen ohne Neustart +- **Entfernte Profil-Bindung**: Tiefe Recherche-Sitzungen koennen an vorkonfigurierte SSH/Remote-Rechenprofile gebunden werden, was reproduzierbare verteilte Forschungs-Workflows ermoeglicht diff --git a/docs/README_FR.md b/docs/README_FR.md index cf71a3c9..8f0d68d1 100644 --- a/docs/README_FR.md +++ b/docs/README_FR.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # InnoClaw

@@ -47,73 +51,71 @@ Il s'adresse aux chercheurs, developpeurs, laboratoires et adeptes du self-hosti -#### 2026-04-01 -- **Competence Text-to-CAD**: Nouvelle competence d'agent qui convertit des descriptions en langage naturel en modeles CAD 3D (STL/STEP) via CadQuery, avec configuration automatique de l'environnement -- **Selecteur d'images de l'espace de travail**: Nouvelle interface de dialogue dans le panneau agent pour parcourir et selectionner des images de l'espace de travail a joindre aux conversations +#### 2026-04-17 +- **CLI InnoClaw**: Lancez l application, gerez les espaces de travail, et creez, executez ou exportez des sessions Deep Research depuis le terminal +- **Points de controle Deep Research**: La recherche peut maintenant faire pause aux etapes de revue pour continuer, reviser, creer une branche, rejeter ou arreter +- **Studio des roles**: Nouvel onglet Deep Research pour examiner les roles specialises et envoyer des consignes ciblees au Researcher ou aux workers -#### 2026-03-31 -- **Support du collage d'images**: Les utilisateurs peuvent maintenant coller des images directement dans la saisie du chat pour des conversations IA multimodales -- **Role Studio pour la recherche approfondie**: Le nouveau panneau Role Studio permet de configurer et gerer des roles de chercheur personnalises dans le flux de recherche approfondie -- **Sources de recherche d'articles elargies**: BioRxiv, PubMed et PubChem ont ete ajoutes comme sources interrogeables dans Paper Study +#### 2026-04-12 +- **Prise en charge du deploiement Docker**: Hebergez InnoClaw vous-meme avec Docker et docker-compose, avec des guides pour l installation, les volumes et les mises a niveau +- **200+ competences integrees**: Forte extension des competences scientifiques pretes a l emploi en bioinformatique, chimie, genomique et physique +- **Cadre de creation de competences**: Nouvelle meta-competence pour creer, evaluer, benchmarker et valider des competences personnalisees

Afficher les mises a jour precedentes -#### 2026-03-26 -- **Decouverte dynamique des modeles**: Le panneau agent recupere automatiquement les modeles disponibles de chaque fournisseur IA configure et les fusionne avec la liste integree -- **Routage Base URL par modele**: Les fournisseurs IA chinois (shlab, qwen, moonshot, deepseek, minimax, zhipu) supportent des variables d'environnement `__BASE_URL` par modele pour un routage flexible -- **Activation des outils a la volee**: Le support des outils peut etre active ou desactive par fournisseur via `_TOOLS_ENABLED=true/false` sans modification du code - +#### 2026-04-02 +- **Support du deploiement Docker**: Ajout d'un Dockerfile, docker-compose.yml et d'un guide complet de deploiement Docker pour les configurations de production auto-hebergees +- **200+ nouveaux skills integres**: Bibliotheque de skills enrichie avec des pipelines en bioinformatique, chemoinformatique, genomique, physique et decouverte de medicaments +- **Framework de creation de skills**: Nouveau meta-skill avec outils d'evaluation, de benchmarking et de validation pour construire et tester des skills personnalises -#### 2026-03-24 -- **Support LLM multimodal**: Les flux de recherche de documents et d'agents prennent desormais en charge les LLM standard et multimodaux (mLLM), selectionnables par contexte dans les parametres et le selecteur de modele. - - +#### 2026-04-01 +- **Competence Text-to-CAD**: Nouvelle competence d'agent qui convertit des descriptions en langage naturel en modeles CAD 3D (STL/STEP) via CadQuery, avec configuration automatique de l'environnement +- **Selecteur d'images de l'espace de travail**: Nouvelle interface de dialogue dans le panneau agent pour parcourir et selectionner des images de l'espace de travail a joindre aux conversations -#### 2026-03-23 -- **Apercu d'importation de competences GitHub**: Nouveau flux de previsualisation avant importation permettant de parcourir, examiner et importer selectivement des competences depuis des depots GitHub +#### 2026-03-31 +- **Support du collage d'images**: Les utilisateurs peuvent maintenant coller des images directement dans la saisie du chat pour des conversations IA multimodales +- **Role Studio pour la recherche approfondie**: Le nouveau panneau Role Studio permet de configurer et gerer des roles de chercheur personnalises dans le flux de recherche approfondie +- **Sources de recherche d'articles elargies**: BioRxiv, PubMed et PubChem ont ete ajoutes comme sources interrogeables dans Paper Study -#### 2026-03-22 -- **Export de notes Obsidian**: Generez des notes structurees compatibles Obsidian avec frontmatter YAML enrichi, figures et wikilinks directement depuis le panneau d'etude de l'article -- **Selecteur de modele par tache**: Un nouveau composant UI de selection de modele permet aux utilisateurs de remplacer le modele AI par defaut pour chaque tache d'etude (resume, critique, notes, etc.) -- **Vue de discussion des notes**: Nouvelle vue de discussion pleine page pour les notes d'articles, permettant des conversations assistees par IA autour du contenu des notes generees +#### 2026-03-26 +- **Decouverte dynamique des modeles**: Le panneau agent recupere automatiquement les modeles disponibles de chaque fournisseur IA configure et les fusionne avec la liste integree +- **Routage Base URL par modele**: Les fournisseurs IA chinois (shlab, qwen, moonshot, deepseek, minimax, zhipu) supportent des variables d'environnement `__BASE_URL` par modele pour un routage flexible +- **Activation des outils a la volee**: Le support des outils peut etre active ou desactive par fournisseur via `_TOOLS_ENABLED=true/false` sans modification du code -#### 2026-03-21 -- **Execution HPC/SLURM distante**: Les sessions de recherche approfondie peuvent desormais s'executer sur des clusters distants via SSH, avec prise en charge de rjob, rlaunch et SLURM, ainsi que la gestion du cycle de vie des taches -- **Interface de configuration Kubernetes**: Nouveau panneau de parametres pour configurer a chaud les contextes K8s, les liaisons PVC et les images de conteneurs sur des deployments multi-clusters sans redemarrage -- **Liaison de profil distant**: Les sessions de recherche approfondie peuvent etre liees a des profils SSH/calcul distant preconfigures, permettant des workflows de recherche distribues et reproductibles +#### 2026-03-24 +- **Support LLM multimodal**: Les flux de recherche de documents et d'agents prennent desormais en charge les LLM standard et multimodaux (mLLM), selectionnables par contexte dans les parametres et le selecteur de modele. -#### 2026-03-20 -- **Module de recherche approfondie**: Pipeline de recherche scientifique entierement pilote par IA avec orchestration multi-phases, deliberation des evaluateurs, planification d'execution et interface graphique de flux de travail -- **Pipeline d'execution**: Systeme d'execution d'experiences automatise avec soumission de jobs Slurm, gestion de jeux de donnees, preprocessement et support d'executeurs distants +#### 2026-03-23 +- **Apercu d'importation de competences GitHub**: Nouveau flux de previsualisation avant importation permettant de parcourir, examiner et importer selectivement des competences depuis des depots GitHub @@ -122,11 +124,11 @@ Il s'adresse aux chercheurs, developpeurs, laboratoires et adeptes du self-hosti -#### 2026-03-19 -- **Importation de competences ClawHub**: Nouvelle integration pour importer des competences directement depuis ClawHub via un point d'API dedie et une boite de dialogue d'importation -- **Panneau de previsualisation du code**: Nouveau composant de previsualisation de code integre a l'editeur, avec coloration syntaxique et suivi de l'etat de sauvegarde -- **Cache de session d'etude**: Couche de mise en cache persistante pour les sessions d'etude de documents, ameliorant les performances de rechargement et la continuite d'etat +#### 2026-03-22 +- **Export de notes Obsidian**: Generez des notes structurees compatibles Obsidian avec frontmatter YAML enrichi, figures et wikilinks directement depuis le panneau d'etude de l'article +- **Selecteur de modele par tache**: Un nouveau composant UI de selection de modele permet aux utilisateurs de remplacer le modele AI par defaut pour chaque tache d'etude (resume, critique, notes, etc.) +- **Vue de discussion des notes**: Nouvelle vue de discussion pleine page pour les notes d'articles, permettant des conversations assistees par IA autour du contenu des notes generees @@ -137,9 +139,10 @@ Il s'adresse aux chercheurs, developpeurs, laboratoires et adeptes du self-hosti -#### 2026-03-18 -- **Vision Multimodale pour l'Analyse d'Articles**: Les images PDF sont desormais extraites et analysees visuellement lors des sessions de discussion et d'ideation de recherche -- **Integration des Competences Claude Code**: Importez des competences directement depuis des dossiers locaux ou des projets Claude Code via un nouveau flux d'importation dedie +#### 2026-03-21 +- **Execution HPC/SLURM distante**: Les sessions de recherche approfondie peuvent desormais s'executer sur des clusters distants via SSH, avec prise en charge de rjob, rlaunch et SLURM, ainsi que la gestion du cycle de vie des taches +- **Interface de configuration Kubernetes**: Nouveau panneau de parametres pour configurer a chaud les contextes K8s, les liaisons PVC et les images de conteneurs sur des deployments multi-clusters sans redemarrage +- **Liaison de profil distant**: Les sessions de recherche approfondie peuvent etre liees a des profils SSH/calcul distant preconfigures, permettant des workflows de recherche distribues et reproductibles diff --git a/docs/README_JA.md b/docs/README_JA.md index a20d5031..067c1781 100644 --- a/docs/README_JA.md +++ b/docs/README_JA.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # InnoClaw

@@ -47,73 +51,71 @@ InnoClaw はサーバー上のフォルダを、文書に根ざした対話、 -#### 2026-04-01 -- **テキスト→CADスキル**: 自然言語の説明をCadQueryで3D CADモデル(STL/STEP)に変換する新しいエージェントスキル。環境のセットアップも自動で行われます -- **ワークスペース画像ピッカー**: エージェントパネルに新たに追加されたダイアログUIで、ワークスペース内の画像を閲覧・選択して会話に添付できます +#### 2026-04-17 +- **InnoClaw コマンドラインツール**: ターミナルからアプリの実行、ワークスペース管理、Deep Research セッションの作成・実行・エクスポートが可能 +- **Deep Research チェックポイント**: 研究は確認ポイントで一時停止し、継続、修正、分岐、却下、停止が可能 +- **ロールスタジオ**: 新しい Deep Research タブで専門ロールを確認し、Researcher や各ワーカーへ個別指示を送信可能 -#### 2026-03-31 -- **画像貼り付けサポート**: チャット入力欄に画像を直接貼り付けて、マルチモーダルAI会話が可能になりました -- **ディープリサーチ・ロールスタジオ**: 新しいロールスタジオパネルで、深度研究ワークフローのカスタム研究者ロールを設定・管理できます -- **論文検索ソースの拡張**: Paper StudyにBioRxiv、PubMed、PubChemが検索可能な論文ソースとして追加されました +#### 2026-04-12 +- **Docker デプロイ対応**: Docker と docker-compose で InnoClaw をセルフホスト可能。セットアップ、ボリューム、アップグレード手順を提供 +- **200+ 内蔵スキル**: すぐ使える科学向けスキルを大幅拡充。生物情報学、化学、ゲノム科学、物理学を広くカバー +- **スキル作成フレームワーク**: カスタムスキルの作成、自動評価、ベンチマーク、検証を行える新しいメタスキル

以前の更新を表示 -#### 2026-03-26 -- **動的モデル検出**: エージェントパネルが各AIプロバイダーから利用可能なモデルを自動取得し、組み込みリストとマージして表示 -- **モデル別 Base URL ルーティング**: 中国系AIプロバイダー(shlab、qwen、moonshot、deepseek、minimax、zhipu)で `__BASE_URL` 環境変数によるモデル単位のエンドポイント設定が可能に -- **実行時ツール呼び出し切り替え**: `_TOOLS_ENABLED=true/false` 環境変数でプロバイダーごとのツール呼び出しをコード変更なしに動的に切り替え可能 - +#### 2026-04-02 +- **Dockerデプロイメントサポート**: Dockerfile、docker-compose.yml、および完全なDockerデプロイメントガイドを追加し、セルフホスト型本番環境に対応 +- **200以上の新しい組み込みスキル**: バイオインフォマティクス、ケモインフォマティクス、ゲノミクス、物理学、創薬パイプラインを含むスキルライブラリを拡充 +- **スキルクリエーターフレームワーク**: カスタムスキルの構築・テスト向けに、評価・ベンチマーク・検証ツールを備えた新メタスキルを追加 -#### 2026-03-24 -- **マルチモーダルLLMサポート**: 論文研究とエージェントワークフローで、標準LLMとマルチモーダルLLM(mLLM)の両方をサポート。設定およびモデルセレクターでコンテキストごとに切り替え可能。 - - +#### 2026-04-01 +- **テキスト→CADスキル**: 自然言語の説明をCadQueryで3D CADモデル(STL/STEP)に変換する新しいエージェントスキル。環境のセットアップも自動で行われます +- **ワークスペース画像ピッカー**: エージェントパネルに新たに追加されたダイアログUIで、ワークスペース内の画像を閲覧・選択して会話に添付できます -#### 2026-03-23 -- **GitHub スキルインポートプレビュー**: インポート前に GitHub リポジトリのスキルを閲覧・確認し、選択してインポートできる新しいプレビューワークフローを追加 +#### 2026-03-31 +- **画像貼り付けサポート**: チャット入力欄に画像を直接貼り付けて、マルチモーダルAI会話が可能になりました +- **ディープリサーチ・ロールスタジオ**: 新しいロールスタジオパネルで、深度研究ワークフローのカスタム研究者ロールを設定・管理できます +- **論文検索ソースの拡張**: Paper StudyにBioRxiv、PubMed、PubChemが検索可能な論文ソースとして追加されました -#### 2026-03-22 -- **Obsidian ノートエクスポート**: 論文スタディパネルから、豊富な YAML フロントマター・図・ウィキリンクを含む構造化された Obsidian 互換ノートを直接生成 -- **タスク別モデル選択**: 新しいモデルセレクター UI により、論文スタディの各タスク(要約・批評・ノートなど)ごとにデフォルト AI モデルを個別に上書き可能 -- **ノートディスカッションビュー**: 論文ノート用の全画面ディスカッションビューを新設し、生成されたノート内容を中心としたスレッド形式の AI 支援会話を実現 +#### 2026-03-26 +- **動的モデル検出**: エージェントパネルが各AIプロバイダーから利用可能なモデルを自動取得し、組み込みリストとマージして表示 +- **モデル別 Base URL ルーティング**: 中国系AIプロバイダー(shlab、qwen、moonshot、deepseek、minimax、zhipu)で `__BASE_URL` 環境変数によるモデル単位のエンドポイント設定が可能に +- **実行時ツール呼び出し切り替え**: `_TOOLS_ENABLED=true/false` 環境変数でプロバイダーごとのツール呼び出しをコード変更なしに動的に切り替え可能 -#### 2026-03-21 -- **リモート HPC/SLURM 実行**: 深層調査セッションが SSH 経由でリモートクラスターで実行可能に。rjob、rlaunch、SLURM スケジューラーに対応し、ファイル転送とジョブライフサイクル管理をサポート -- **Kubernetes クラスター設定 UI**: K8s コンテキスト、PVC バインディング、コンテナイメージをマルチクラスター環境で再起動なしに動的設定できる新しい設定パネル -- **リモートプロファイルバインディング**: 深層調査セッションを事前設定済みの SSH/リモート計算プロファイルに紐付け、再現可能な分散型研究ワークフローを実現 +#### 2026-03-24 +- **マルチモーダルLLMサポート**: 論文研究とエージェントワークフローで、標準LLMとマルチモーダルLLM(mLLM)の両方をサポート。設定およびモデルセレクターでコンテキストごとに切り替え可能。 -#### 2026-03-20 -- **深層リサーチモジュール**: 多段階オーケストレーション、レビュアー討議、実行計画、ワークフローグラフUIを備えた完全AI驱動の科学研究パイプライン -- **実行パイプライン**: Slurmジョブ送信、データセット管理、前処理、リモート実行器をサポートする自動化実験実行システム +#### 2026-03-23 +- **GitHub スキルインポートプレビュー**: インポート前に GitHub リポジトリのスキルを閲覧・確認し、選択してインポートできる新しいプレビューワークフローを追加 @@ -122,11 +124,11 @@ InnoClaw はサーバー上のフォルダを、文書に根ざした対話、 -#### 2026-03-19 -- **ClawHub スキルインポート**: ClawHub から専用 API エンドポイントとインポートダイアログを通じて、スキルを直接インポートできる新しい連携機能 -- **コードプレビューパネル**: シンタックスハイライトと保存状態のトラッキングに対応した、エディタ内コードプレビューコンポーネントを新規追加 -- **論文学習キャッシュ**: 論文学習セッションの永続的なキャッシュ層を追加し、再読み込みのパフォーマンスと状態の継続性を向上 +#### 2026-03-22 +- **Obsidian ノートエクスポート**: 論文スタディパネルから、豊富な YAML フロントマター・図・ウィキリンクを含む構造化された Obsidian 互換ノートを直接生成 +- **タスク別モデル選択**: 新しいモデルセレクター UI により、論文スタディの各タスク(要約・批評・ノートなど)ごとにデフォルト AI モデルを個別に上書き可能 +- **ノートディスカッションビュー**: 論文ノート用の全画面ディスカッションビューを新設し、生成されたノート内容を中心としたスレッド形式の AI 支援会話を実現 @@ -137,9 +139,10 @@ InnoClaw はサーバー上のフォルダを、文書に根ざした対話、 -#### 2026-03-18 -- **論文分析のマルチモーダルビジョン**: 論文ディスカッションおよびリサーチアイデア出しセッション中に、PDF 画像を抽出してビジュアル分析できるようになりました -- **Claude Code スキル統合**: 新しい専用インポートワークフローにより、ローカルフォルダーまたは Claude Code プロジェクトからスキルを直接インポートできます +#### 2026-03-21 +- **リモート HPC/SLURM 実行**: 深層調査セッションが SSH 経由でリモートクラスターで実行可能に。rjob、rlaunch、SLURM スケジューラーに対応し、ファイル転送とジョブライフサイクル管理をサポート +- **Kubernetes クラスター設定 UI**: K8s コンテキスト、PVC バインディング、コンテナイメージをマルチクラスター環境で再起動なしに動的設定できる新しい設定パネル +- **リモートプロファイルバインディング**: 深層調査セッションを事前設定済みの SSH/リモート計算プロファイルに紐付け、再現可能な分散型研究ワークフローを実現 diff --git a/docs/_static/custom.css b/docs/_static/custom.css index eed4b709..c7026b01 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -18,3 +18,43 @@ border-color: var(--sy-c-link); background-color: var(--sy-c-bg-mute); } + +html[lang="en"] .workflow-overview-zh, +html[lang="en-us"] .workflow-overview-zh, +html[lang="en-gb"] .workflow-overview-zh { + display: none; +} + +html[lang="zh-cn"] .workflow-overview-en, +html[lang="zh"] .workflow-overview-en { + display: none; +} + +html[lang="en"] .language-diagram-zh, +html[lang="en-us"] .language-diagram-zh, +html[lang="en-gb"] .language-diagram-zh { + display: none; +} + +html[lang="zh-cn"] .language-diagram-en, +html[lang="zh"] .language-diagram-en { + display: none; +} + +.workflow-overview-en, +.workflow-overview-zh { + margin: 1rem 0 1.5rem; +} + +.workflow-overview-en img, +.workflow-overview-zh img { + width: min(420px, 100%); + border: 1px solid var(--sy-c-divider); + border-radius: 12px; + background: var(--sy-c-bg); +} + +.language-diagram-en, +.language-diagram-zh { + margin: 1rem 0; +} diff --git a/docs/_static/images/development/workflow-overview-en.png b/docs/_static/images/development/workflow-overview-en.png new file mode 100644 index 0000000000000000000000000000000000000000..a4984ca713fc82e41f59565d41b5c94634954421 GIT binary patch literal 128215 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

Cg@Q-Jg}uS9?xW&LxT5^g69> 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

    J|5T?2<(BmS8 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

    XPE512)MM7t5XGBiA+Q{ul- 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 + {%- if show_copyright -%} +

    {{ copyright }}

    + {% endif %} +

    + {% if is_zh %}使用 {% else %}Made with {% endif %} + {% if show_sphinx %} + Sphinx{% if is_zh %} 与 {% else %} and {% endif %} + {% endif %} + {% if is_zh %}Shibuya 主题{% else %}Shibuya theme{% endif %}{% if is_zh %} 构建。{% else %}.{% endif %} +

    + diff --git a/docs/conf.py b/docs/conf.py index ab45b735..4645a061 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,3 +1,5 @@ +import sys + # Configuration file for the Sphinx documentation builder. # # For the full list of built-in configuration values, see the documentation: @@ -28,6 +30,7 @@ source_suffix = { ".md": "markdown", } +templates_path = ["_templates"] # The master toctree document master_doc = "index" @@ -44,13 +47,25 @@ # -- Options for HTML output ------------------------------------------------- html_theme = "shibuya" +is_zh = str(language).lower().startswith("zh") or any( + arg.lower() == "language=zh_cn" for arg in sys.argv +) + html_theme_options = { "github_url": "https://github.com/SpectrAI-Initiative/InnoClaw", - "nav_links": [ - {"title": "Getting Started", "url": "getting-started/overview"}, - {"title": "Usage", "url": "usage/features"}, - {"title": "Development", "url": "development/contributing"}, - ], + "nav_links": ( + [ + {"title": "快速入门", "url": "getting-started/overview"}, + {"title": "使用", "url": "usage/features"}, + {"title": "开发", "url": "development/contributing"}, + ] + if is_zh + else [ + {"title": "Getting Started", "url": "getting-started/overview"}, + {"title": "Usage", "url": "usage/features"}, + {"title": "Development", "url": "development/contributing"}, + ] + ), } html_baseurl = "https://SpectrAI-Initiative.github.io/InnoClaw/" @@ -64,7 +79,7 @@ html_static_path = ["_static"] html_css_files = ["custom.css"] -html_title = "InnoClaw Documentation" +html_title = "InnoClaw 文档" if is_zh else "InnoClaw Documentation" # -- Options for linkcheck --------------------------------------------------- linkcheck_ignore = [ diff --git a/docs/development/agent-development.md b/docs/development/agent-development.md new file mode 100644 index 00000000..2aec3c7e --- /dev/null +++ b/docs/development/agent-development.md @@ -0,0 +1,142 @@ +# Agent Development + +This page covers contributor expectations for agent-related features, tool-calling flows, and deep-research orchestration inside InnoClaw. + +## Main Code Areas + +Agent and research-execution behavior is spread across a few core areas: + +- `src/app/api/agent/` and `src/app/api/deep-research/` for HTTP entry points +- `src/lib/ai/` for provider selection, prompts, runtime capability checks, and tool registration +- `src/lib/ai/tools/` for individual tool implementations and shared tool types +- `src/lib/agent/` for stream lifecycle and persistence behavior +- `src/lib/deep-research/` for doctrine loading, roles, orchestration, workflow policy, and execution helpers +- `src/lib/skills/` for skill import and repository-backed skill workflows +- `src/components/agent/` and `src/components/deep-research/` for UI surfaces that expose agent state and workflow progress + +## Architectural Rules + +- Keep route handlers thin. Parse input, validate context, select the right orchestration path, and hand off non-trivial logic to `src/lib/`. +- Keep prompts in prompt modules or doctrine/role files instead of embedding long prompt text inside route handlers or React components. +- Keep provider, model, and runtime-capability logic centralized in `src/lib/ai/` so behavior stays consistent across agent entry points. +- Treat `src/lib/ai/tool-names.ts` as the public contract for tool identity and privilege tiering. + +## Agent Change Workflow + +
    + +```{mermaid} +flowchart LR + Start["Plan an agent change"] --> Type{"What changed?"} + Type -- "Prompt or role" --> Prompt["Review prompt files, doctrine, parsers, and UI labels"] + Type -- "Tool or privilege" --> Tool["Review tool names, gates, selectors, and allow/deny tests"] + Type -- "Streaming or session" --> Stream["Review route, stream manager, persistence, and resume UI"] + Type -- "Deep-research workflow" --> Research["Review doctrine, roles, workflow policy, routes, and UI states"] + Prompt --> Verify["Add tests and update docs if contributor-facing"] + Tool --> Verify + Stream --> Verify + Research --> Verify + Verify --> Audit{"High-risk execution path?"} + Audit -- "Yes" --> Approval["Confirm approval gates and audit trail remain intact"] + Audit -- "No" --> Ship["Prepare handoff with changed contracts and validation"] + Approval --> Ship +``` +
    + +
    + +```{mermaid} +flowchart LR + Start["规划一次智能体变更"] --> Type{"变更类型是什么?"} + Type -- "Prompt 或角色" --> Prompt["联查 prompt 文件、doctrine、解析器与 UI 标签"] + Type -- "工具或权限" --> Tool["联查工具名称、门控、选择器与 allow/deny 测试"] + Type -- "流式输出或会话" --> Stream["联查路由、stream manager、持久化与恢复 UI"] + Type -- "Deep-research 工作流" --> Research["联查 doctrine、角色、工作流策略、路由与 UI 状态"] + Prompt --> Verify["补充测试,并在影响贡献者时更新文档"] + Tool --> Verify + Stream --> Verify + Research --> Verify + Verify --> Audit{"是否涉及高风险执行路径?"} + Audit -- "是" --> Approval["确认审批门控与审计链路仍然完整"] + Audit -- "否" --> Ship["准备交接说明,并列出变更契约与验证"] + Approval --> Ship +``` +
    + +Use this workflow before you open a PR for agent-related changes. It is a quick way to catch the most common cross-layer regressions. + +## Change Matrix + +When you touch one of these agent-facing contracts, review the matching surfaces together: + +- Prompt, doctrine, or role-text change: prompt modules, doctrine files, role registries, dependent parsers, and UI copy or status labels that assume those names or instructions. +- New tool or tool rename: `src/lib/ai/tool-names.ts`, the tool implementation, allow/deny logic, any selector UI, and contributor docs that describe the capability. +- Privilege-tier or approval-flow change: tool gating, runtime capability checks, UI affordances, and tests for both allowed and denied paths. +- Streaming or session-persistence change: the route entry point, `src/lib/agent/agent-stream-manager.ts`, persistence helpers, and UI components that resume or display in-flight state. +- Deep-research workflow change: doctrine loading, role registry, workflow policy, artifact/status types, API routes, and the UI components that assume those steps or states exist. + +## Tooling And Privilege Boundaries + +- Add new tool implementations under `src/lib/ai/tools/`. +- Use shared tool context and validation helpers instead of introducing ad hoc filesystem or shell access. +- Default new tools to least privilege. If a capability can mutate infrastructure or trigger remote execution, gate it like the existing high-privilege tool sets. +- Keep tool names stable unless you are intentionally performing a contract migration. Renames require tests, docs, and any UI selector surfaces to be updated together. +- Do not let prompt text become the only guardrail for risky capabilities. Hard privilege boundaries belong in typed runtime checks and tool registration. + +## Streaming, Sessions, And UI State + +- If you change how agent streams are produced or consumed, review `src/lib/agent/agent-stream-manager.ts` and mounted UI behavior together. +- Preserve resume and persistence semantics when the panel unmounts, tabs switch, or background work continues. +- Be careful with localStorage-backed keys and session identifiers. Treat them as shared contracts between route, runtime, and UI layers. +- Prefer explicit transition states and surfaced errors over silent fallback behavior that hides dropped events, unsupported tools, or resume failures. + +## Deep-Research And Multi-Role Flows + +- Keep doctrine loading, role definitions, and workflow policy aligned across `researcher-doctrine.ts`, `role-registry.ts`, and `workflow-policy.ts`. +- When changing role prompts or collaboration behavior, review the corresponding API routes and UI components that assume those artifacts, steps, or statuses exist. +- High-risk execution paths should remain approval-driven and auditable. +- When adding a new role, artifact type, or workflow status, verify how it appears in persistence, export/reporting paths, and the operator-facing review surfaces. + +## Failure Handling And Observability + +- Return explicit, typed failure states where the client or operator needs to react differently. +- Log enough request, session, provider, or tool context to debug orchestration failures without reproducing everything from scratch. +- Avoid silent provider fallback or tool suppression unless the user experience clearly communicates what capability was skipped. +- Keep approval checkpoints and audit trails intact when changing remote execution, cluster operations, or other high-risk flows. + +## Testing Expectations + +Choose tests based on the layer you changed: + +- Route behavior: `src/app/api/**/route.test.ts` +- Provider or model-selection behavior: tests under `src/lib/ai/` +- Tool contract or parsing behavior: tests under `src/lib/ai/` or `src/lib/skills/` +- Stream/session persistence behavior: tests near `src/lib/agent/` or affected UI helpers +- Deep-research workflow logic: tests under `src/lib/deep-research/` + +At minimum, new agent-facing behavior should include coverage for: + +- validation and error paths +- privilege or tool-access boundaries +- provider/runtime branching where behavior differs +- any persisted session or workflow state changes + +## Agent Contributor Checklist + +Before requesting review for agent-related work, confirm: + +- the relevant contract surfaces from the change matrix were reviewed together +- tests cover both success and failure or deny paths +- docs were updated if another contributor or operator needs to understand the new capability +- handoff notes call out changed tool names, approval boundaries, persisted keys, workflow statuses, or environment requirements + +## Documentation Expectations + +Update contributor docs when agent-related changes affect: + +- setup or required environment variables +- available tools, privilege boundaries, or execution gates +- contributor workflow for testing, debugging, or local development +- route or workflow contracts that another developer is likely to extend + +If a change is primarily internal but creates a new developer extension point, document that extension point here or in the most relevant development page. diff --git a/docs/development/collaboration.md b/docs/development/collaboration.md new file mode 100644 index 00000000..70a40eed --- /dev/null +++ b/docs/development/collaboration.md @@ -0,0 +1,143 @@ +# Collaboration + +This page defines how contributors should collaborate in a shared repository, whether the other contributor is a human teammate or an automation tool. + +## Shared-Tree Workflow + +
    + +```{mermaid} +flowchart LR + Start["Start work"] --> Status["Check git status"] + Status --> Dirty{"Dirty or shared worktree?"} + Dirty -- "No" --> Scope["Confirm task scope and contracts"] + Dirty -- "Yes" --> ReRead["Re-read active files and identify unrelated changes"] + ReRead --> Conflict{"Direct conflict?"} + Conflict -- "Yes" --> Coordinate["Pause and coordinate before editing"] + Conflict -- "No" --> Scope + Scope --> Edit["Make focused edits"] + Edit --> Contract{"Changed shared contract?"} + Contract -- "Yes" --> Update["Update tests, docs, and handoff notes"] + Contract -- "No" --> Validate["Run required validation"] + Update --> Validate + Validate --> Handoff["Share summary, contracts, validation, and follow-ups"] +``` +
    + +
    + +```{mermaid} +flowchart LR + Start["开始工作"] --> Status["检查 git status"] + Status --> Dirty{"是否为脏工作树或共享工作树?"} + Dirty -- "否" --> Scope["确认任务范围与共享契约"] + Dirty -- "是" --> ReRead["重读活跃文件并识别无关改动"] + ReRead --> Conflict{"是否存在直接冲突?"} + Conflict -- "是" --> Coordinate["暂停编辑并先协调"] + Conflict -- "否" --> Scope + Scope --> Edit["进行聚焦编辑"] + Edit --> Contract{"是否修改了共享契约?"} + Contract -- "是" --> Update["同步更新测试、文档与交接说明"] + Contract -- "否" --> Validate["运行所需验证"] + Update --> Validate + Validate --> Handoff["共享摘要、契约、验证与后续事项"] +``` +
    + +This is the default collaboration path when more than one contributor or tool may touch the tree. + +## Shared-Tree Discipline + +- Start by checking the current worktree state before large edits. +- Treat unrelated local changes as owned by someone else unless you have clear evidence they are disposable. +- Do not use destructive git commands to discard changes you did not make. +- Re-read files that are actively changing before editing them, especially large route handlers, shared utilities, and docs entry points. + +## Before You Edit + +- Decide whether the task is one concern or several. Split branches or PRs when the work mixes behavior changes, refactors, and contributor-workflow updates. +- Identify the shared contracts you might change before editing. Schema, env vars, route shapes, tool names, persisted client keys, and contributor commands all need explicit follow-through. +- Decide which validation commands and documentation updates will be required before you start writing code. + +## Scope Discipline + +- Keep each branch or PR focused on one engineering concern. +- Separate refactors from behavior changes unless the refactor is required to make the behavior change safe. +- Keep migrations, environment-variable changes, and API contract changes explicit rather than hiding them inside broad cleanup diffs. +- Keep generated artifacts that are required by the source change, such as migrations or `.po` updates, in the same change so reviewers can validate the full contract shift. + +## Shared Contracts + +Coordinate carefully when changing any of these repository-wide contracts: + +- Database schema and migrations +- Environment variables and startup assumptions +- API request and response shapes +- Tool names, tool privilege levels, and skill contracts +- Persisted client/session keys such as localStorage-backed agent state +- Documentation entry points used by contributors + +When you change a shared contract: + +- update the relevant docs in the same change +- update tests for the contract boundary +- call out the change in the PR or handoff summary + +## Reviewability + +- Use descriptive branch names and Conventional Commits. +- Prefer reviewable patches over large mixed diffs. +- Include the exact verification commands you ran and the result you observed. +- For UI or workflow changes, include screenshots, short recordings, or precise reproduction notes when practical. + +## Review And Handoff Checklist + +Before asking for review or handing work to another contributor, include: + +- what changed and the user-facing or maintainer-facing impact +- which shared contracts, migrations, or risky files deserve extra review +- the exact validation commands you ran and the result you observed +- any known follow-ups, rollout steps, or unresolved risks + +Use a compact handoff format when possible: + +```text +Summary: +Contracts: +Validation: +Follow-ups: +``` + +### Example Handoff + +```text +Summary: Added repository and agent contribution flow diagrams to the development docs. +Contracts: Contributor guidance changed in AGENTS.md and docs/development/*.md; translation catalogs refreshed. +Validation: npm run lint; npm test; NEXT_TELEMETRY_DISABLED=1 npm run build; sphinx-build en/zh with -W --keep-going. +Follow-ups: Translate newly added Chinese msgstr entries if localized docs need complete coverage. +``` + +## Human And Automation Collaboration + +- Point automation tools at `AGENTS.md` before asking them to edit the repository. +- Give automation bounded tasks with clear file or subsystem scope. +- Review generated diffs with the same skepticism you would apply to a human contribution. +- Re-run validation after automation work instead of trusting success claims from the tool. +- If an automation task changes contributor workflow, docs, or architecture boundaries, make sure the human reviewer sees those changes first. +- Tell automation what is out of bounds, which contracts are sensitive, and which verification commands are required before it starts editing. +- Treat automation summaries as hints, not truth. The reviewer is still responsible for the final contract, risk, and verification checks. + +## Documentation And Translation Workflow + +- English Markdown source files are the documentation source of truth. +- When English docs change, refresh `.po` files so translation drift is visible immediately. +- Keep repository-level contributor guidance aligned across `AGENTS.md`, `CONTRIBUTING.md`, and `docs/development/`. + +## When To Stop And Coordinate + +Pause and coordinate before proceeding if: + +- another contributor's changes directly conflict with your task +- you need to rewrite a shared contract used by multiple subsystems +- you are unsure whether a local-only directory is actually being treated as source by someone else's workflow +- a migration or execution-path change could break existing environments without a clear rollout path diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 9dc64021..e7efc614 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -7,14 +7,75 @@ Thank you for your interest in contributing to InnoClaw! This guide explains how 1. **Fork** the repository on GitHub 2. **Clone** your fork locally: ```bash - git clone https://github.com/your-username/notebooklm.git - cd notebooklm + git clone https://github.com/your-username/InnoClaw.git + cd InnoClaw ``` 3. **Install** dependencies: ```bash npm install ``` -4. Follow the [Installation Guide](../getting-started/installation.md) to set up your development environment +4. Read the repository-level guide in [Repository Guidelines](repository-guidelines.md) +5. Follow the [Installation Guide](../getting-started/installation.md) to set up your development environment + +Useful follow-up pages: + +- [Collaboration](collaboration.md) +- [Agent Development](agent-development.md) + +## Which Guide To Use + +- Use [Repository Guidelines](repository-guidelines.md) as the repository-wide source of truth for local workflow, validation, and documentation follow-through. +- Use [Collaboration](collaboration.md) when the worktree is already dirty, when you are coordinating across multiple contributors, or when an automation tool is helping with edits. +- Use [Agent Development](agent-development.md) before changing prompts, tools, agent streaming, deep-research roles, or other agent-facing contracts. + +## Contribution Flow + +
    + +```{mermaid} +flowchart LR + Start["Start contribution"] --> Branch["Create a focused branch"] + Branch --> Scope{"Touches shared contracts?"} + Scope -- "Yes" --> ReadDocs["Read AGENTS.md and matching docs/development pages"] + Scope -- "No" --> Implement["Implement the change"] + ReadDocs --> Implement + Implement --> Docs{"Contributor-facing behavior changed?"} + Docs -- "Yes" --> UpdateDocs["Update docs and related examples"] + Docs -- "No" --> Validate["Run lint, test, and build"] + UpdateDocs --> Validate + Validate --> Ready{"Checks passed?"} + Ready -- "No" --> Fix["Fix code, tests, or docs"] + Fix --> Validate + Ready -- "Yes" --> PR["Open PR with summary, validation, and contract notes"] +``` +
    + +
    + +```{mermaid} +flowchart LR + Start["开始贡献"] --> Branch["创建聚焦分支"] + Branch --> Scope{"是否涉及共享契约?"} + Scope -- "是" --> ReadDocs["阅读 AGENTS.md 与对应 docs/development 页面"] + Scope -- "否" --> Implement["实现变更"] + ReadDocs --> Implement + Implement --> Docs{"面向贡献者的行为是否变更?"} + Docs -- "是" --> UpdateDocs["更新文档与相关示例"] + Docs -- "否" --> Validate["运行 lint、test 与 build"] + UpdateDocs --> Validate + Validate --> Ready{"检查是否通过?"} + Ready -- "否" --> Fix["修复代码、测试或文档"] + Fix --> Validate + Ready -- "是" --> PR["提交 PR,并附摘要、验证与契约说明"] +``` +
    + +Use this flow as the default path for most changes: + +1. Start from a focused branch. +2. Detect early whether the change touches contracts such as schema, env vars, route shapes, or agent capabilities. +3. Update documentation in the same change when contributor-facing behavior moves. +4. Only request review after local validation passes. ## Branching Strategy @@ -66,19 +127,23 @@ docs(api): update endpoint documentation 3. **Run checks** before submitting: ```bash npm run lint - npm run build npm test + NEXT_TELEMETRY_DISABLED=1 npm run build ``` 4. **Push** your branch and open a Pull Request 5. **Describe** your changes clearly in the PR description 6. **Wait for review** — maintainers will review your code +If your change updates contributor workflow, environment setup, or developer-facing behavior, update the relevant pages under `docs/development/` in the same PR. + +If your change updates agent or deep-research behavior, verify that tool names, privilege boundaries, session persistence, and contributor docs stay aligned. + ### PR Checklist - [ ] Code follows the project's coding style - [ ] Tests pass (`npm test`) - [ ] Lint passes (`npm run lint`) -- [ ] Build succeeds (`npm run build`) +- [ ] Build succeeds (`NEXT_TELEMETRY_DISABLED=1 npm run build`) - [ ] Documentation updated (if applicable) ## Code of Conduct diff --git a/docs/development/readme-homepage-redesign.md b/docs/development/readme-homepage-redesign.md index 4016ac06..fca41fd4 100644 --- a/docs/development/readme-homepage-redesign.md +++ b/docs/development/readme-homepage-redesign.md @@ -1,3 +1,7 @@ +--- +orphan: true +--- + # README Homepage Redesign Plan ## Goal diff --git a/docs/development/repository-guidelines.md b/docs/development/repository-guidelines.md new file mode 100644 index 00000000..3563ade5 --- /dev/null +++ b/docs/development/repository-guidelines.md @@ -0,0 +1,56 @@ +# Repository Guidelines + +This page is the documentation-site entry for repository development workflow. + +The canonical source of truth remains `AGENTS.md` at the repository root in a local clone. Use this page when you are browsing the published docs site, and use `AGENTS.md` when you are working inside the repository itself. + +## Source Of Truth + +When instructions overlap, use this order: + +1. `AGENTS.md` at the repository root +2. `package.json` scripts and `docs/Makefile` +3. `.github/workflows/*.yml` +4. Supporting pages under `docs/development/` + +## What This Covers + +- Supported local environment +- Repository boundaries for code vs. scratch content +- Validation commands before review +- Collaboration expectations in a shared worktree +- Database migration expectations +- API route responsibilities +- Agent and deep-research development expectations +- Documentation update requirements + +## Workflow Overview + +```{image} /_static/images/development/workflow-overview-en.png +:alt: English overview of the developer workflow from FigJam +:class: workflow-overview-en +``` + +```{image} /_static/images/development/workflow-overview-zh.png +:alt: Chinese overview of the developer workflow from FigJam +:class: workflow-overview-zh +``` + +- [English FigJam Board](https://www.figma.com/board/WFNaqCm92fh8ySjas6txi0/InnoClaw-Developer-Workflow-Overview?node-id=0-1&p=f) +- [Chinese FigJam Board](https://www.figma.com/board/bSNAwMgaZmu4DXZisidzXx/InnoClaw-%E5%BC%80%E5%8F%91%E6%B5%81%E7%A8%8B%E6%A6%82%E8%A7%88?node-id=0-1&p=f) + +## Which Guide To Read Next + +- Read [Contributing](contributing.md) for the default branch, commit, and PR workflow. +- Read [Collaboration](collaboration.md) before working in a dirty tree, coordinating with another contributor, or handing work to automation. +- Read [Agent Development](agent-development.md) before changing prompts, tools, streaming behavior, deep-research workflow logic, or other agent-facing contracts. + +## Next Pages + +- [Contributing](contributing.md) +- [Collaboration](collaboration.md) +- [Project Structure](project-structure.md) +- [Local Development](local-development.md) +- [Testing](testing.md) +- [Agent Development](agent-development.md) +- [Documentation Development](documentation.md) diff --git a/docs/getting-started/environment-variables.md b/docs/getting-started/environment-variables.md index 3160ed44..3f4d85c8 100644 --- a/docs/getting-started/environment-variables.md +++ b/docs/getting-started/environment-variables.md @@ -8,7 +8,8 @@ A complete reference of all environment variables used by InnoClaw. |----------|------|----------|---------|-------------| | `WORKSPACE_ROOTS` | `string` | **Yes** | — | Comma-separated absolute paths where workspaces can be created. Directories must exist on the server. | | `DATABASE_URL` | `string` | No | `./data/innoclaw.db` | SQLite database filesystem path. Set to a local path when the project resides on NFS or another network filesystem. | -| `NEXT_BUILD_DIR` | `string` | No | `.next` | Next.js build output directory. Set to a local filesystem path to avoid Turbopack cache errors on network/shared filesystems. | +| `AUTH_SECRET` | `string` | Recommended | Development fallback | Long random secret used to sign local authentication session cookies. Set this in production. | +| `NEXT_BUILD_DIR` | `string` | No | `.next` | Next.js build output directory inside the project root (for example `.next-local`). On Next.js 16 / Turbopack this cannot point outside the repo. | ## AI Provider Configuration diff --git a/docs/index.md b/docs/index.md index ec0e9437..6e434c92 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ Welcome to the **InnoClaw** documentation — an AI-powered research assistant w Users open server-side folders as workspaces, browse and manage files, and chat with AI grounded in workspace files via RAG (Retrieval-Augmented Generation). +For contributor workflow inside a repository clone, start with the repository root `AGENTS.md`, then use the development pages below for detailed setup, testing, and documentation procedures. + ```{toctree} :maxdepth: 2 :caption: Getting Started @@ -35,10 +37,13 @@ notifications/index :maxdepth: 2 :caption: Development Guide +development/repository-guidelines development/contributing +development/collaboration development/project-structure development/local-development development/testing +development/agent-development development/documentation ``` @@ -48,13 +53,3 @@ development/documentation troubleshooting/faq ``` - -```{toctree} -:hidden: - -README_CN -README_JA -README_FR -README_DE -development/readme-homepage-redesign -``` diff --git a/docs/locales/zh_CN/LC_MESSAGES/development/agent-development.po b/docs/locales/zh_CN/LC_MESSAGES/development/agent-development.po new file mode 100644 index 00000000..df4321ac --- /dev/null +++ b/docs/locales/zh_CN/LC_MESSAGES/development/agent-development.po @@ -0,0 +1,452 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, InnoClaw Contributors +# This file is distributed under the same license as the InnoClaw package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: InnoClaw \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-13 23:29+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language: zh_CN\n" +"Language-Team: zh_CN \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: ../../development/agent-development.md:1 e3fe5e23b31947b5bcb640fc817aa635 +msgid "Agent Development" +msgstr "智能体开发" + +#: ../../development/agent-development.md:3 33cdfb09881d4ef59ec73901867c662e +msgid "" +"This page covers contributor expectations for agent-related features, " +"tool-calling flows, and deep-research orchestration inside InnoClaw." +msgstr "" +"本页说明了 InnoClaw 中与智能体相关功能、工具调用流程以及 deep-research 编排有关的贡献" +"者要求。" + +#: ../../development/agent-development.md:5 679ec279afdc42a0a58db519b6ad7710 +msgid "Main Code Areas" +msgstr "主要代码区域" + +#: ../../development/agent-development.md:7 611c34af22384525a1b124be506a89bd +msgid "Agent and research-execution behavior is spread across a few core areas:" +msgstr "智能体与研究执行相关的行为分布在几个核心区域中:" + +#: ../../development/agent-development.md:9 3ac81a89325948469d6b4d752d9ef26e +msgid "" +"`src/app/api/agent/` and `src/app/api/deep-research/` for HTTP entry " +"points" +msgstr "" +"`src/app/api/agent/` 与 `src/app/api/deep-research/` 负责 HTTP 入口" + +#: ../../development/agent-development.md:10 f92ff8d2aad749bba09cc1756e03ffef +msgid "" +"`src/lib/ai/` for provider selection, prompts, runtime capability checks," +" and tool registration" +msgstr "" +"`src/lib/ai/` 负责 provider 选择、prompts、运行时能力检查以及工具注册" + +#: ../../development/agent-development.md:11 9486199bf6954d6cb25f1c8d32a301fc +msgid "" +"`src/lib/ai/tools/` for individual tool implementations and shared tool " +"types" +msgstr "" +"`src/lib/ai/tools/` 负责具体工具实现与共享工具类型" + +#: ../../development/agent-development.md:12 f9bcf7e187124ff087d87d7dd80f5075 +msgid "`src/lib/agent/` for stream lifecycle and persistence behavior" +msgstr "`src/lib/agent/` 负责流式生命周期与持久化行为" + +#: ../../development/agent-development.md:13 0967f4bef95c4651813a7673edba1fe3 +msgid "" +"`src/lib/deep-research/` for doctrine loading, roles, orchestration, " +"workflow policy, and execution helpers" +msgstr "" +"`src/lib/deep-research/` 负责 doctrine 加载、角色定义、编排、工作流策略与执行辅助逻" +"辑" + +#: ../../development/agent-development.md:14 35fbfa12d45a4ca19fd1a56b2d52dc7b +msgid "`src/lib/skills/` for skill import and repository-backed skill workflows" +msgstr "`src/lib/skills/` 负责技能导入以及以仓库为后端的技能工作流" + +#: ../../development/agent-development.md:15 ec6c5a4081524e99bc2110b5604b908f +msgid "" +"`src/components/agent/` and `src/components/deep-research/` for UI " +"surfaces that expose agent state and workflow progress" +msgstr "" +"`src/components/agent/` 与 `src/components/deep-research/` 负责暴露智能体状态与工作" +"流进度的 UI 界面" + +#: ../../development/agent-development.md:17 0a863a66eb5e480fa2e38ed042776dbf +msgid "Architectural Rules" +msgstr "架构规则" + +#: ../../development/agent-development.md:19 05be12eebd5045ceb9457b5540037b49 +msgid "" +"Keep route handlers thin. Parse input, validate context, select the right" +" orchestration path, and hand off non-trivial logic to `src/lib/`." +msgstr "" +"保持 route handler 足够轻薄。它们只负责解析输入、校验上下文、选择正确的编排路径,并将" +"非简单逻辑下放到 `src/lib/`。" + +#: ../../development/agent-development.md:20 71a94702e96448959d4ac635dd5c2513 +msgid "" +"Keep prompts in prompt modules or doctrine/role files instead of " +"embedding long prompt text inside route handlers or React components." +msgstr "" +"将 prompts 放在 prompt 模块或 doctrine/role 文件中,不要把大段 prompt 文本直接嵌入 " +"route handler 或 React 组件里。" + +#: ../../development/agent-development.md:21 38422d0cc2f040039928c8b9bd6c86ba +msgid "" +"Keep provider, model, and runtime-capability logic centralized in " +"`src/lib/ai/` so behavior stays consistent across agent entry points." +msgstr "" +"将 provider、model 与运行时能力逻辑集中在 `src/lib/ai/` 中,以保证各个智能体入口行为" +"一致。" + +#: ../../development/agent-development.md:22 6803894ecccf482fb588f4d25ae80873 +msgid "" +"Treat `src/lib/ai/tool-names.ts` as the public contract for tool identity" +" and privilege tiering." +msgstr "" +"将 `src/lib/ai/tool-names.ts` 视为工具标识与权限分层的公开契约。" + +#: ../../development/agent-development.md:24 864583050d3f46f281f1fd3dfb9eeaef +msgid "Agent Change Workflow" +msgstr "智能体变更流程" + +#: ../../development/agent-development.md:43 4f8ecf3a31ef4c39be1d94c91645ded7 +msgid "" +"Use this workflow before you open a PR for agent-related changes. It is a" +" quick way to catch the most common cross-layer regressions." +msgstr "" +"在为智能体相关变更提交 PR 之前,请先使用这套流程。它能快速帮您识别最常见的跨层回归。" + +#: ../../development/agent-development.md:45 62410e68cbdf4c56af4e8564f7d9df74 +msgid "Change Matrix" +msgstr "变更矩阵" + +#: ../../development/agent-development.md:47 321fc573639140cc8b3475b31c46a50e +msgid "" +"When you touch one of these agent-facing contracts, review the matching " +"surfaces together:" +msgstr "" +"当您修改以下任一面向智能体的契约时,请一并检查对应的联动面:" + +#: ../../development/agent-development.md:49 989f439eece14ba0865f2e54c5fee958 +msgid "" +"Prompt, doctrine, or role-text change: prompt modules, doctrine files, " +"role registries, dependent parsers, and UI copy or status labels that " +"assume those names or instructions." +msgstr "" +"Prompt、doctrine 或角色文本变更:同时检查 prompt 模块、doctrine 文件、角色注册表、依" +"赖它们的解析器,以及依赖这些名称或指令的 UI 文案与状态标签。" + +#: ../../development/agent-development.md:50 d7a53b8bd7bb470798ac8d0a7757408e +msgid "" +"New tool or tool rename: `src/lib/ai/tool-names.ts`, the tool " +"implementation, allow/deny logic, any selector UI, and contributor docs " +"that describe the capability." +msgstr "" +"新增工具或工具重命名:同时检查 `src/lib/ai/tool-names.ts`、工具实现、allow/deny 逻" +"辑、相关选择器 UI,以及描述该能力的贡献者文档。" + +#: ../../development/agent-development.md:51 0e69caba7e014c568746a9b53985e211 +msgid "" +"Privilege-tier or approval-flow change: tool gating, runtime capability " +"checks, UI affordances, and tests for both allowed and denied paths." +msgstr "" +"权限层级或审批流程变更:同时检查工具门控、运行时能力检查、UI 交互提示,以及允许/拒绝两" +"条路径的测试。" + +#: ../../development/agent-development.md:52 9b55b21bf2a44df5a75e54d53a7afedc +msgid "" +"Streaming or session-persistence change: the route entry point, " +"`src/lib/agent/agent-stream-manager.ts`, persistence helpers, and UI " +"components that resume or display in-flight state." +msgstr "" +"流式输出或会话持久化变更:同时检查 route 入口、`src/lib/agent/agent-stream-manager." +"ts`、持久化辅助逻辑,以及负责恢复或展示进行中状态的 UI 组件。" + +#: ../../development/agent-development.md:53 3d09a8652b0743f4b4e2d49fafd21cba +msgid "" +"Deep-research workflow change: doctrine loading, role registry, workflow " +"policy, artifact/status types, API routes, and the UI components that " +"assume those steps or states exist." +msgstr "" +"Deep-research 工作流变更:同时检查 doctrine 加载、角色注册表、工作流策略、artifact/" +"status 类型、API 路由,以及依赖这些步骤或状态存在的 UI 组件。" + +#: ../../development/agent-development.md:55 9e4fabc9acbf4635a7ba2b7e71b1da0c +msgid "Tooling And Privilege Boundaries" +msgstr "工具与权限边界" + +#: ../../development/agent-development.md:57 3467480e44474024b9864234d340f885 +msgid "Add new tool implementations under `src/lib/ai/tools/`." +msgstr "将新工具实现添加到 `src/lib/ai/tools/` 下。" + +#: ../../development/agent-development.md:58 19c8fbd9aa154cc68ed2c15ccee2685f +msgid "" +"Use shared tool context and validation helpers instead of introducing ad " +"hoc filesystem or shell access." +msgstr "" +"优先复用共享的工具上下文与校验辅助逻辑,不要临时引入零散的文件系统或 shell 访问。" + +#: ../../development/agent-development.md:59 940d936a2b124ec78c9922450be6af05 +msgid "" +"Default new tools to least privilege. If a capability can mutate " +"infrastructure or trigger remote execution, gate it like the existing " +"high-privilege tool sets." +msgstr "" +"新工具默认遵循最小权限原则。如果某项能力可能修改基础设施或触发远程执行,就应像现有高权" +"限工具集一样进行门控。" + +#: ../../development/agent-development.md:60 11ad83175e8940fa9255e7d96e6552fc +msgid "" +"Keep tool names stable unless you are intentionally performing a contract" +" migration. Renames require tests, docs, and any UI selector surfaces to " +"be updated together." +msgstr "" +"除非您明确在做契约迁移,否则应保持工具名称稳定。重命名需要同步更新测试、文档以及所有相" +"关的 UI 选择器界面。" + +#: ../../development/agent-development.md:61 6c4430390d4c401ea49d687872fb8546 +msgid "" +"Do not let prompt text become the only guardrail for risky capabilities. " +"Hard privilege boundaries belong in typed runtime checks and tool " +"registration." +msgstr "" +"不要让 prompt 文本成为高风险能力的唯一防线。真正的权限边界应体现在带类型的运行时检查与" +"工具注册中。" + +#: ../../development/agent-development.md:63 8cc9cc94199d4cc09bee5d39d35f96b8 +msgid "Streaming, Sessions, And UI State" +msgstr "流式输出、会话与 UI 状态" + +#: ../../development/agent-development.md:65 21325a4f30fe40e4be7259a6aa14e9d0 +msgid "" +"If you change how agent streams are produced or consumed, review " +"`src/lib/agent/agent-stream-manager.ts` and mounted UI behavior together." +msgstr "" +"如果您修改了智能体流式输出的生成或消费方式,请一并检查 `src/lib/agent/agent-stream-" +"manager.ts` 以及已挂载 UI 的行为。" + +#: ../../development/agent-development.md:66 a1fa6fad2eaa4619b264d17bfebd5694 +msgid "" +"Preserve resume and persistence semantics when the panel unmounts, tabs " +"switch, or background work continues." +msgstr "" +"当面板卸载、标签切换或后台任务继续运行时,必须保持恢复与持久化语义不变。" + +#: ../../development/agent-development.md:67 d19464fd948946e28f45cd474c573ea8 +msgid "" +"Be careful with localStorage-backed keys and session identifiers. Treat " +"them as shared contracts between route, runtime, and UI layers." +msgstr "" +"谨慎处理由 localStorage 支撑的键与会话标识符。应将它们视为 route、runtime 与 UI 各层" +"之间的共享契约。" + +#: ../../development/agent-development.md:68 d25662030a484e7f8556846217d1a8a0 +msgid "" +"Prefer explicit transition states and surfaced errors over silent " +"fallback behavior that hides dropped events, unsupported tools, or resume" +" failures." +msgstr "" +"优先使用显式的状态迁移与可见错误,而不是静默 fallback;静默 fallback 容易掩盖事件丢" +"失、工具不受支持或恢复失败。" + +#: ../../development/agent-development.md:70 8341f6f047604143b58527217803995b +msgid "Deep-Research And Multi-Role Flows" +msgstr "Deep-Research 与多角色流程" + +#: ../../development/agent-development.md:72 f5c7b9656323437386027f753778f138 +msgid "" +"Keep doctrine loading, role definitions, and workflow policy aligned " +"across `researcher-doctrine.ts`, `role-registry.ts`, and `workflow-" +"policy.ts`." +msgstr "" +"让 `researcher-doctrine.ts`、`role-registry.ts` 与 `workflow-policy.ts` 之间的 " +"doctrine 加载、角色定义与工作流策略保持一致。" + +#: ../../development/agent-development.md:73 3d09a8652b0743f4b4e2d49fafd21cba +msgid "" +"When changing role prompts or collaboration behavior, review the " +"corresponding API routes and UI components that assume those artifacts, " +"steps, or statuses exist." +msgstr "" +"当修改角色 prompts 或协作行为时,请检查依赖这些 artifact、步骤或状态存在的 API 路由与" +" UI 组件。" + +#: ../../development/agent-development.md:74 c137d484b4af490b9bb13b300c8d4e40 +msgid "High-risk execution paths should remain approval-driven and auditable." +msgstr "高风险执行路径必须继续保持审批驱动,并且可审计。" + +#: ../../development/agent-development.md:75 fcfd38033bda4b5e87af165352481080 +msgid "" +"When adding a new role, artifact type, or workflow status, verify how it " +"appears in persistence, export/reporting paths, and the operator-facing " +"review surfaces." +msgstr "" +"当新增角色、artifact 类型或工作流状态时,请验证它在持久化、导出/报告路径以及面向操作" +"者的评审界面中是如何呈现的。" + +#: ../../development/agent-development.md:77 dc2f538e91964ff893fbb92edbc65569 +msgid "Failure Handling And Observability" +msgstr "失败处理与可观测性" + +#: ../../development/agent-development.md:79 050e1dadf3dc490794049a7f2dd00a42 +msgid "" +"Return explicit, typed failure states where the client or operator needs " +"to react differently." +msgstr "" +"在客户端或操作员需要做出不同处理时,应返回显式、带类型的失败状态。" + +#: ../../development/agent-development.md:80 189dda81745a458cad8ad0e9939c2935 +msgid "" +"Log enough request, session, provider, or tool context to debug " +"orchestration failures without reproducing everything from scratch." +msgstr "" +"记录足够的 request、session、provider 或 tool 上下文,以便在无需从头复现的情况下定位" +"编排失败。" + +#: ../../development/agent-development.md:81 f17174d6fdc64579b4c720a3f5026325 +msgid "" +"Avoid silent provider fallback or tool suppression unless the user " +"experience clearly communicates what capability was skipped." +msgstr "" +"除非用户体验明确告知了被跳过的能力,否则应避免静默 provider fallback 或静默抑制工" +"具。" + +#: ../../development/agent-development.md:82 df5ed62b145944cea8ea6635682e715e +msgid "" +"Keep approval checkpoints and audit trails intact when changing remote " +"execution, cluster operations, or other high-risk flows." +msgstr "" +"在修改远程执行、集群操作或其他高风险流程时,必须保持审批检查点与审计链路完整。" + +#: ../../development/agent-development.md:84 c76a8c2bf92243cc97af73d9ae4ca1ec +msgid "Testing Expectations" +msgstr "测试要求" + +#: ../../development/agent-development.md:86 49a1d2326082422796fe7da3dc73d99c +msgid "Choose tests based on the layer you changed:" +msgstr "请根据您修改的层级选择测试:" + +#: ../../development/agent-development.md:88 5dee6f4c24b34e79848e59448b4b4937 +msgid "Route behavior: `src/app/api/**/route.test.ts`" +msgstr "路由行为:`src/app/api/**/route.test.ts`" + +#: ../../development/agent-development.md:89 f6631753962f426e8b07772c5162436e +msgid "Provider or model-selection behavior: tests under `src/lib/ai/`" +msgstr "Provider 或模型选择行为:`src/lib/ai/` 下的测试" + +#: ../../development/agent-development.md:90 aa12eb28647e4c3584a4fa627978c17b +msgid "" +"Tool contract or parsing behavior: tests under `src/lib/ai/` or " +"`src/lib/skills/`" +msgstr "" +"工具契约或解析行为:`src/lib/ai/` 或 `src/lib/skills/` 下的测试" + +#: ../../development/agent-development.md:91 8c703842bb354ac59c8acfcfb4642b91 +msgid "" +"Stream/session persistence behavior: tests near `src/lib/agent/` or " +"affected UI helpers" +msgstr "" +"流式/会话持久化行为:`src/lib/agent/` 附近或受影响 UI 辅助逻辑附近的测试" + +#: ../../development/agent-development.md:92 2b4e7dfd8e6c40c883a3979aaf046e04 +msgid "Deep-research workflow logic: tests under `src/lib/deep-research/`" +msgstr "Deep-research 工作流逻辑:`src/lib/deep-research/` 下的测试" + +#: ../../development/agent-development.md:94 a389774c1e764fbb990345aabb912566 +msgid "At minimum, new agent-facing behavior should include coverage for:" +msgstr "至少,新引入的面向智能体行为应覆盖以下方面:" + +#: ../../development/agent-development.md:96 a9a87c18ea374c7588446df93fdcad17 +msgid "validation and error paths" +msgstr "校验与错误路径" + +#: ../../development/agent-development.md:97 624d5c14c72f45fcb4e607c6dceda9d7 +msgid "privilege or tool-access boundaries" +msgstr "权限或工具访问边界" + +#: ../../development/agent-development.md:98 64b16cb96b5147b08f84d7a14cc05a78 +msgid "provider/runtime branching where behavior differs" +msgstr "在行为不同处的 provider/runtime 分支" + +#: ../../development/agent-development.md:99 9fa54560a65144d1a810442bef2114ce +msgid "any persisted session or workflow state changes" +msgstr "任何持久化的会话或工作流状态变化" + +#: ../../development/agent-development.md:101 864583050d3f46f281f1fd3dfb9eeaef +msgid "Agent Contributor Checklist" +msgstr "智能体贡献者检查清单" + +#: ../../development/agent-development.md:103 53e0ac086e134fb3aee7a407aad0fdf1 +msgid "Before requesting review for agent-related work, confirm:" +msgstr "在为智能体相关工作请求评审前,请确认:" + +#: ../../development/agent-development.md:105 effc94a1a4574411999829e18ab7519f +msgid "" +"the relevant contract surfaces from the change matrix were reviewed " +"together" +msgstr "" +"变更矩阵中相关的契约联动面已被一并检查" + +#: ../../development/agent-development.md:106 7d7c322c24894fe6b6e9dbf191bdad3e +msgid "tests cover both success and failure or deny paths" +msgstr "测试同时覆盖成功路径与失败/拒绝路径" + +#: ../../development/agent-development.md:107 8cbf99d594014f8bbb37912ef792161f +msgid "" +"docs were updated if another contributor or operator needs to understand " +"the new capability" +msgstr "" +"如果其他贡献者或操作员需要理解这项新能力,文档已经同步更新" + +#: ../../development/agent-development.md:108 ee7cc4f40aaa437a8d6b3beaf926382b +msgid "" +"handoff notes call out changed tool names, approval boundaries, persisted" +" keys, workflow statuses, or environment requirements" +msgstr "" +"交接说明已经明确指出变化的工具名称、审批边界、持久化键、工作流状态或环境要求" + +#: ../../development/agent-development.md:110 6e33c9d84c824b22bb14f8af398aa73e +msgid "Documentation Expectations" +msgstr "文档要求" + +#: ../../development/agent-development.md:112 d47daf776b0f474aafa4d82e0f814ec6 +msgid "Update contributor docs when agent-related changes affect:" +msgstr "当智能体相关变更影响以下内容时,请更新贡献者文档:" + +#: ../../development/agent-development.md:114 dc19d6734e3b4f238eb261a1b97c8b16 +msgid "setup or required environment variables" +msgstr "安装配置或必需环境变量" + +#: ../../development/agent-development.md:115 7be72691c94d4007b58970c3bf705cd2 +msgid "available tools, privilege boundaries, or execution gates" +msgstr "可用工具、权限边界或执行门控" + +#: ../../development/agent-development.md:116 e8bb76d650064c58946a9c70493168b6 +msgid "contributor workflow for testing, debugging, or local development" +msgstr "贡献者在测试、调试或本地开发中的工作流" + +#: ../../development/agent-development.md:117 be64763fda6c421cb091d448f5e899bb +msgid "route or workflow contracts that another developer is likely to extend" +msgstr "其他开发者很可能会继续扩展的路由或工作流契约" + +#: ../../development/agent-development.md:119 2c493000b9e24242965c24cf7eec2ed0 +msgid "" +"If a change is primarily internal but creates a new developer extension " +"point, document that extension point here or in the most relevant " +"development page." +msgstr "" +"如果某项变更主要是内部实现,但引入了新的开发者扩展点,请在这里或最相关的开发文档页中记" +"录该扩展点。" diff --git a/docs/locales/zh_CN/LC_MESSAGES/development/collaboration.po b/docs/locales/zh_CN/LC_MESSAGES/development/collaboration.po new file mode 100644 index 00000000..34ac4e1c --- /dev/null +++ b/docs/locales/zh_CN/LC_MESSAGES/development/collaboration.po @@ -0,0 +1,339 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, InnoClaw Contributors +# This file is distributed under the same license as the InnoClaw package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: InnoClaw \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-13 23:29+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language: zh_CN\n" +"Language-Team: zh_CN \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: ../../development/collaboration.md:1 4cd2aaa810df45409c3b62c0df3c8b8e +msgid "Collaboration" +msgstr "协作规范" + +#: ../../development/collaboration.md:3 05137b1b0178431d9cf6d4f247217750 +msgid "" +"This page defines how contributors should collaborate in a shared " +"repository, whether the other contributor is a human teammate or an " +"automation tool." +msgstr "" +"本页定义了贡献者在共享仓库中应如何协作,不论另一位贡献者是人类队友还是自动化工具。" + +#: ../../development/collaboration.md:5 5675fe3425c44d82adf29e9483aa4593 +msgid "Shared-Tree Workflow" +msgstr "共享工作树流程" + +#: ../../development/collaboration.md:24 e0a295d5aa664e8ca574059c36752792 +msgid "" +"This is the default collaboration path when more than one contributor or " +"tool may touch the tree." +msgstr "" +"当多个贡献者或工具都可能修改当前工作树时,这就是默认协作路径。" + +#: ../../development/collaboration.md:26 5675fe3425c44d82adf29e9483aa4593 +msgid "Shared-Tree Discipline" +msgstr "共享工作树纪律" + +#: ../../development/collaboration.md:28 9455d58ce68c42cdbb75d2a4c2eaec71 +msgid "Start by checking the current worktree state before large edits." +msgstr "在进行大规模编辑之前,先检查当前工作树状态。" + +#: ../../development/collaboration.md:29 289f56671c374a7eb1f08776e7466b9d +msgid "" +"Treat unrelated local changes as owned by someone else unless you have " +"clear evidence they are disposable." +msgstr "" +"对于与当前任务无关的本地改动,除非您有明确证据证明它们可丢弃,否则都应视为他人的工作。" + +#: ../../development/collaboration.md:30 48a528505810409095b52f26cb2a3d12 +msgid "Do not use destructive git commands to discard changes you did not make." +msgstr "不要使用破坏性的 git 命令丢弃并非由您产生的改动。" + +#: ../../development/collaboration.md:31 94d785d524fa497a96b5db687b37d808 +msgid "" +"Re-read files that are actively changing before editing them, especially " +"large route handlers, shared utilities, and docs entry points." +msgstr "" +"在编辑正在频繁变化的文件之前,请先重新阅读其最新内容,尤其是大型 route handler、共享工" +"具以及文档入口文件。" + +#: ../../development/collaboration.md:33 163f1a403a9541e9b66161aee5a0d9c4 +msgid "Before You Edit" +msgstr "开始编辑前" + +#: ../../development/collaboration.md:35 252d63803e0f45238d2db8c1b9f82d84 +msgid "" +"Decide whether the task is one concern or several. Split branches or PRs " +"when the work mixes behavior changes, refactors, and contributor-workflow" +" updates." +msgstr "" +"先判断这项任务是单一关注点还是多个关注点。若一个变更同时混入行为调整、重构和贡献者工作" +"流更新,请拆分成不同分支或 PR。" + +#: ../../development/collaboration.md:36 ae7c46e31fd443a48c229d08982bcc81 +msgid "" +"Identify the shared contracts you might change before editing. Schema, " +"env vars, route shapes, tool names, persisted client keys, and " +"contributor commands all need explicit follow-through." +msgstr "" +"在编辑前先识别您可能会改动的共享契约。Schema、环境变量、路由返回形状、工具名称、持久化" +"客户端键以及贡献者命令都需要明确的后续处理。" + +#: ../../development/collaboration.md:37 aa28e5dc4fc44403a6e4ab9aed6ec79b +msgid "" +"Decide which validation commands and documentation updates will be " +"required before you start writing code." +msgstr "" +"在开始写代码前就确定需要运行哪些验证命令,以及需要同步更新哪些文档。" + +#: ../../development/collaboration.md:39 a036304b432441bca8de1f6f55c27c12 +msgid "Scope Discipline" +msgstr "范围纪律" + +#: ../../development/collaboration.md:41 7c51ec4a1f6343529b1615aafd6dca48 +msgid "Keep each branch or PR focused on one engineering concern." +msgstr "让每个分支或 PR 都聚焦于一个工程关注点。" + +#: ../../development/collaboration.md:42 96044617ee8d422cacfcf8cc10491d3d +msgid "" +"Separate refactors from behavior changes unless the refactor is required " +"to make the behavior change safe." +msgstr "" +"除非重构是保证行为变更安全所必需的,否则应将重构与行为修改分开。" + +#: ../../development/collaboration.md:43 25bc22530a3442649ca1ce215e4d727d +msgid "" +"Keep migrations, environment-variable changes, and API contract changes " +"explicit rather than hiding them inside broad cleanup diffs." +msgstr "" +"迁移、环境变量修改和 API 契约变更要显式呈现,不要把它们藏在大而泛的清理型 diff 里。" + +#: ../../development/collaboration.md:44 12bf25d6f8d349ba8160bf696d21048b +msgid "" +"Keep generated artifacts that are required by the source change, such as " +"migrations or `.po` updates, in the same change so reviewers can validate" +" the full contract shift." +msgstr "" +"像迁移文件或 `.po` 更新这类由源变更所要求的生成产物,应与源变更放在同一个提交中,便于" +"评审者验证完整的契约变化。" + +#: ../../development/collaboration.md:46 a51752cf285f4a0d9c8fd63f3be881c6 +msgid "Shared Contracts" +msgstr "共享契约" + +#: ../../development/collaboration.md:48 93b71dd8eb3541e1bee47f40b0344717 +msgid "Coordinate carefully when changing any of these repository-wide contracts:" +msgstr "当您修改以下任何仓库级共享契约时,都需要谨慎协同:" + +#: ../../development/collaboration.md:50 48be05564a6f4784b46ee59c0adb1800 +msgid "Database schema and migrations" +msgstr "数据库 schema 与迁移" + +#: ../../development/collaboration.md:51 bd454a0832a4497db92933ce6107a7af +msgid "Environment variables and startup assumptions" +msgstr "环境变量与启动假设" + +#: ../../development/collaboration.md:52 0df1eeaf3c1d460b89b400f0ea110df2 +msgid "API request and response shapes" +msgstr "API 请求与响应形状" + +#: ../../development/collaboration.md:53 3f1123598568482e9c929dbf5e143d2d +msgid "Tool names, tool privilege levels, and skill contracts" +msgstr "工具名称、工具权限等级与技能契约" + +#: ../../development/collaboration.md:54 9970151851b24388aaf056b8bcbc741f +msgid "Persisted client/session keys such as localStorage-backed agent state" +msgstr "持久化的客户端/会话键,例如由 localStorage 支撑的智能体状态" + +#: ../../development/collaboration.md:55 f64135490a2642b8bf72a0126aaf4a1a +msgid "Documentation entry points used by contributors" +msgstr "贡献者使用的文档入口页" + +#: ../../development/collaboration.md:57 7dea80f5501f42f3a03d6a5863d7825a +msgid "When you change a shared contract:" +msgstr "当您修改共享契约时:" + +#: ../../development/collaboration.md:59 f9d80b1e66c24d068f746f252895c222 +msgid "update the relevant docs in the same change" +msgstr "在同一个变更中更新相关文档" + +#: ../../development/collaboration.md:60 4b4800050a0f4789bddcbcba6998d479 +msgid "update tests for the contract boundary" +msgstr "更新契约边界的测试" + +#: ../../development/collaboration.md:61 3b6148b47af54abe9306094dddd91a84 +msgid "call out the change in the PR or handoff summary" +msgstr "在 PR 或交接摘要中明确说明该变化" + +#: ../../development/collaboration.md:63 69674fa80e424b7b9619d142b39ae8c1 +msgid "Reviewability" +msgstr "可评审性" + +#: ../../development/collaboration.md:65 89e1f444e8cd445780fe074e07b239a7 +msgid "Use descriptive branch names and Conventional Commits." +msgstr "使用具描述性的分支名与 Conventional Commits。" + +#: ../../development/collaboration.md:66 acdcfd7894d742bb9361f895d2d21e09 +msgid "Prefer reviewable patches over large mixed diffs." +msgstr "优先提交便于评审的小型补丁,而不是大而混杂的 diff。" + +#: ../../development/collaboration.md:67 80194695240a4483b09f85fbe80205da +msgid "" +"Include the exact verification commands you ran and the result you " +"observed." +msgstr "" +"写明您实际运行过的验证命令以及观察到的结果。" + +#: ../../development/collaboration.md:68 ac7447cb3ff74b3ba2f1d75bdb5e2dae +msgid "" +"For UI or workflow changes, include screenshots, short recordings, or " +"precise reproduction notes when practical." +msgstr "" +"对于 UI 或工作流变更,在可行时附上截图、短录屏或精确的复现说明。" + +#: ../../development/collaboration.md:70 f141daac09dc4ca6b261a6a44683ef70 +msgid "Review And Handoff Checklist" +msgstr "评审与交接清单" + +#: ../../development/collaboration.md:72 5928d578330843108f2c537a933a2b8c +msgid "Before asking for review or handing work to another contributor, include:" +msgstr "在请求评审或将工作移交给其他贡献者之前,请包含以下内容:" + +#: ../../development/collaboration.md:74 40d50a1f1d9c4b7abcca33ed83ce1258 +msgid "what changed and the user-facing or maintainer-facing impact" +msgstr "变更了什么,以及它对用户或维护者有什么影响" + +#: ../../development/collaboration.md:75 941a5f5b06ae4d57a44a4fac7ece7c14 +msgid "which shared contracts, migrations, or risky files deserve extra review" +msgstr "哪些共享契约、迁移或高风险文件需要额外关注" + +#: ../../development/collaboration.md:76 80194695240a4483b09f85fbe80205da +msgid "the exact validation commands you ran and the result you observed" +msgstr "您实际运行过的验证命令以及观察到的结果" + +#: ../../development/collaboration.md:77 8049c7e2ebf24a229fa8644850181547 +msgid "any known follow-ups, rollout steps, or unresolved risks" +msgstr "任何已知的后续事项、上线步骤或尚未解决的风险" + +#: ../../development/collaboration.md:79 dda6521ebae84a0ead32f5f7a03330c5 +msgid "Use a compact handoff format when possible:" +msgstr "在可能的情况下,使用简洁的交接格式:" + +#: ../../development/collaboration.md:88 8ebefe5cc65f4ed7b5dcd30eb9fd5dca +msgid "Example Handoff" +msgstr "交接示例" + +#: ../../development/collaboration.md:97 ed958671554e491586d90456c429d40e +msgid "Human And Automation Collaboration" +msgstr "人与自动化的协作" + +#: ../../development/collaboration.md:99 d2dd771889bd422f8af2136dc23e89be +msgid "" +"Point automation tools at `AGENTS.md` before asking them to edit the " +"repository." +msgstr "" +"在要求自动化工具修改仓库之前,先让它们阅读 `AGENTS.md`。" + +#: ../../development/collaboration.md:100 d3124f5ec35e4699b77a9e4aeed77730 +msgid "Give automation bounded tasks with clear file or subsystem scope." +msgstr "给自动化工具分配边界清晰、文件或子系统范围明确的任务。" + +#: ../../development/collaboration.md:101 59a4a8fded964b41862ebf4b588ba0be +msgid "" +"Review generated diffs with the same skepticism you would apply to a " +"human contribution." +msgstr "" +"审查自动化生成的 diff 时,应保持与审查人工提交同样的怀疑态度。" + +#: ../../development/collaboration.md:102 cda3cc47af5c474eb97fcc4a91d676fd +msgid "" +"Re-run validation after automation work instead of trusting success " +"claims from the tool." +msgstr "" +"自动化工具完成后要重新运行验证,而不是直接相信它的成功声明。" + +#: ../../development/collaboration.md:103 a7b917f0db5a492388e2f333a1e76f68 +msgid "" +"If an automation task changes contributor workflow, docs, or architecture" +" boundaries, make sure the human reviewer sees those changes first." +msgstr "" +"如果自动化任务修改了贡献者工作流、文档或架构边界,请确保人工评审者先看到这些变化。" + +#: ../../development/collaboration.md:104 9e9ee66dd31c4a3099d4b270ebd7138e +msgid "" +"Tell automation what is out of bounds, which contracts are sensitive, and" +" which verification commands are required before it starts editing." +msgstr "" +"在自动化开始编辑前,要明确告诉它哪些内容不可触碰、哪些契约敏感、以及必须运行哪些验证命" +"令。" + +#: ../../development/collaboration.md:105 7927a7ff26c744a5a196eaf5b0dbf8a7 +msgid "" +"Treat automation summaries as hints, not truth. The reviewer is still " +"responsible for the final contract, risk, and verification checks." +msgstr "" +"把自动化工具的摘要当作线索,而不是真相。最终的契约、风险和验证检查责任仍在评审者。" + +#: ../../development/collaboration.md:107 1adf2cc98c6e4298b20aca74e32d1220 +msgid "Documentation And Translation Workflow" +msgstr "文档与翻译工作流" + +#: ../../development/collaboration.md:109 ffa318d1591c4139b700bf7863707f61 +msgid "English Markdown source files are the documentation source of truth." +msgstr "英文 Markdown 源文件是文档的权威来源。" + +#: ../../development/collaboration.md:110 807f2eb6de614006b41cbce9b431aa49 +msgid "" +"When English docs change, refresh `.po` files so translation drift is " +"visible immediately." +msgstr "" +"当英文文档发生变化时,请刷新 `.po` 文件,以便翻译漂移能被立即发现。" + +#: ../../development/collaboration.md:111 c2e15b9e94e3436385c25e94c31990b3 +msgid "" +"Keep repository-level contributor guidance aligned across `AGENTS.md`, " +"`CONTRIBUTING.md`, and `docs/development/`." +msgstr "" +"让仓库级贡献者指南在 `AGENTS.md`、`CONTRIBUTING.md` 与 `docs/development/` 之间保持" +"一致。" + +#: ../../development/collaboration.md:113 6065bb71ee9c41dea82d8dcc2dab4b1f +msgid "When To Stop And Coordinate" +msgstr "何时应暂停并协调" + +#: ../../development/collaboration.md:115 04cd57ad24df458997b42075d378699c +msgid "Pause and coordinate before proceeding if:" +msgstr "若出现以下情况,请先暂停并协调,再继续推进:" + +#: ../../development/collaboration.md:117 be893c0b24234d1aa97cf585f05f48e8 +msgid "another contributor's changes directly conflict with your task" +msgstr "另一位贡献者的改动与您的任务直接冲突" + +#: ../../development/collaboration.md:118 b07be8123de644b5b6fc9ee86defa782 +msgid "you need to rewrite a shared contract used by multiple subsystems" +msgstr "您需要重写一个被多个子系统使用的共享契约" + +#: ../../development/collaboration.md:119 ff8640c3893942009dacc27eaafc4d2e +msgid "" +"you are unsure whether a local-only directory is actually being treated " +"as source by someone else's workflow" +msgstr "" +"您不确定某个仅本地使用的目录,是否实际上已被他人的工作流当作源码输入" + +#: ../../development/collaboration.md:120 0357cba0e73444b8b77d4c5ddae105a0 +msgid "" +"a migration or execution-path change could break existing environments " +"without a clear rollout path" +msgstr "" +"某项迁移或执行路径变更可能在没有明确发布路径的情况下破坏现有环境" diff --git a/docs/locales/zh_CN/LC_MESSAGES/development/contributing.po b/docs/locales/zh_CN/LC_MESSAGES/development/contributing.po index 17478318..e93fc8b1 100644 --- a/docs/locales/zh_CN/LC_MESSAGES/development/contributing.po +++ b/docs/locales/zh_CN/LC_MESSAGES/development/contributing.po @@ -3,12 +3,11 @@ # This file is distributed under the same license as the InnoClaw package. # FIRST AUTHOR , 2026. # -#, fuzzy msgid "" msgstr "" "Project-Id-Version: InnoClaw \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-03 12:05+0000\n" +"POT-Creation-Date: 2026-04-13 23:29+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: zh_CN\n" @@ -19,212 +18,310 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.18.0\n" -#: ../../development/contributing.md:1 60c529d25cd94260bd80928306bbb222 +#: ../../development/contributing.md:1 a3c520acb6054bc2babb2b6a12d0e6c5 msgid "Contributing" msgstr "贡献指南" -#: ../../development/contributing.md:3 6736fb2430344dd591e56e9274a0beca +#: ../../development/contributing.md:3 400875a05f51454b9258a6b9e7b535d0 msgid "" "Thank you for your interest in contributing to InnoClaw! This guide " "explains how to get involved." msgstr "感谢您有兴趣为 InnoClaw 做出贡献!本指南介绍如何参与。" -#: ../../development/contributing.md:5 a58734115662459faf001650cd5f1343 +#: ../../development/contributing.md:5 03a1fa04db4c4bcd800f286087bad5fc msgid "Getting Started" msgstr "快速入门" -#: ../../development/contributing.md:7 f77b3d38011e42c599d96d3346c44d30 +#: ../../development/contributing.md:7 681b97f583b24560aff594ec45eaaaf8 msgid "**Fork** the repository on GitHub" msgstr "在 GitHub 上 **Fork** 仓库" -#: ../../development/contributing.md:8 f0372a707a2b4315a68fd173f4957002 +#: ../../development/contributing.md:8 5d51278588cf439d953ed0a02cbc4063 msgid "**Clone** your fork locally:" msgstr "在本地**克隆**您的 Fork:" -#: ../../development/contributing.md:13 a4bcb931451246e985887b338e512be4 +#: ../../development/contributing.md:13 e73506f288ae486180916fad3bcc4f77 msgid "**Install** dependencies:" msgstr "**安装**依赖:" -#: ../../development/contributing.md:17 b5cd27b8d117418a851ac46da7cd3886 +#: ../../development/contributing.md:17 0337446b73d3455c9ac76a6f4de4519c +msgid "" +"Read the repository-level guide in [Repository Guidelines](repository-" +"guidelines.md)" +msgstr "" +"阅读仓库级指南 [仓库规范](repository-guidelines.md)" + +#: ../../development/contributing.md:18 6bf30038b6c5447f96faa9babacd7ca1 msgid "" "Follow the [Installation Guide](../getting-started/installation.md) to " "set up your development environment" msgstr "按照[安装指南](../getting-started/installation.md)搭建开发环境" -#: ../../development/contributing.md:19 a15241293ff2456d8b526e76ec25b0ce +#: ../../development/contributing.md:20 1cace1c8a75a4fecbf64489a8aef14a0 +msgid "Useful follow-up pages:" +msgstr "推荐继续阅读:" + +#: ../../development/contributing.md:22 08621cc3bb97438b86091ead4eda2d8a +msgid "[Collaboration](collaboration.md)" +msgstr "[协作规范](collaboration.md)" + +#: ../../development/contributing.md:23 96a7d948c020496383d2c1c070d9d54e +msgid "[Agent Development](agent-development.md)" +msgstr "[智能体开发](agent-development.md)" + +#: ../../development/contributing.md:25 1f88766ea5954e5190c0be254aeedb3c +msgid "Which Guide To Use" +msgstr "该看哪份指南" + +#: ../../development/contributing.md:27 4dd4c5378d194bcead663172c4c48e9b +msgid "" +"Use [Repository Guidelines](repository-guidelines.md) as the repository-" +"wide source of truth for local workflow, validation, and documentation " +"follow-through." +msgstr "" +"将 [仓库规范](repository-guidelines.md) 作为本仓库本地工作流、验证流程与文档联动更新" +"的统一权威来源。" + +#: ../../development/contributing.md:28 d2ffb47a6db94bac8d1d1b28dcc57c09 +msgid "" +"Use [Collaboration](collaboration.md) when the worktree is already dirty," +" when you are coordinating across multiple contributors, or when an " +"automation tool is helping with edits." +msgstr "" +"当工作树已经是脏状态、需要与多位贡献者协作,或有自动化工具参与编辑时,请阅读 " +"[协作规范](collaboration.md)。" + +#: ../../development/contributing.md:29 b16f0c75d467413bb524c231a68afa8b +msgid "" +"Use [Agent Development](agent-development.md) before changing prompts, " +"tools, agent streaming, deep-research roles, or other agent-facing " +"contracts." +msgstr "" +"在修改 prompts、tools、智能体流式输出、deep-research 角色,或其他面向智能体的契约之前," +"请阅读 [智能体开发](agent-development.md)。" + +#: ../../development/contributing.md:31 da1410f6977b4ee5a546613da3f60039 +msgid "Contribution Flow" +msgstr "贡献流程" + +#: ../../development/contributing.md:50 166f67235a8c4149b97ea2808c4fdde3 +msgid "Use this flow as the default path for most changes:" +msgstr "大多数变更都可以按这个流程推进:" + +#: ../../development/contributing.md:52 5d6ee0364d3e46a8b70ac5b9957f7f0e +msgid "Start from a focused branch." +msgstr "从一个聚焦的分支开始。" + +#: ../../development/contributing.md:53 63dece9e009c45fd9aff129f060819ce +msgid "" +"Detect early whether the change touches contracts such as schema, env " +"vars, route shapes, or agent capabilities." +msgstr "" +"尽早识别这次变更是否触及 schema、环境变量、路由返回形状或智能体能力等共享契约。" + +#: ../../development/contributing.md:54 10b203af328a41b1abaae81c3fb4a5b4 +msgid "" +"Update documentation in the same change when contributor-facing behavior " +"moves." +msgstr "" +"当面向贡献者的行为发生变化时,在同一个变更中同步更新文档。" + +#: ../../development/contributing.md:55 b09cd25233a14313bce36905f66443a3 +msgid "Only request review after local validation passes." +msgstr "只有在本地验证通过之后再请求评审。" + +#: ../../development/contributing.md:57 9a12de280f0848e58e330511a4cdbfed msgid "Branching Strategy" msgstr "分支策略" -#: ../../development/contributing.md:21 b548b45d4ee14d62aff7f89826e38ebd +#: ../../development/contributing.md:59 d7511eaec61646fbbe83808af815158e msgid "`main` — Stable production branch" msgstr "`main` —— 稳定的生产分支" -#: ../../development/contributing.md:22 23bde497b3b240c182fa92f285d83b46 +#: ../../development/contributing.md:60 adf04c154a1e4e78a38e2b75014a49c0 msgid "Feature branches — Created from `main` for new features or bug fixes" msgstr "功能分支 —— 从 `main` 创建,用于新功能或错误修复" -#: ../../development/contributing.md:24 6ca5c35193a7430bbf123bd3ff0e7d0d +#: ../../development/contributing.md:62 91c9e39f87854233be5e5c8ee4acc15f msgid "Branch Naming Convention" msgstr "分支命名规范" -#: ../../development/contributing.md:32 7bf146197915464189c910841d4e7dff +#: ../../development/contributing.md:70 b7b722bdeea74b4f95be3b141f5aedd0 msgid "Commit Conventions" msgstr "提交规范" -#: ../../development/contributing.md:34 510fbd99444e4f9da1b7e2afdbfeb135 +#: ../../development/contributing.md:72 70bd3be896ec4949baf53845b325eba9 msgid "We follow [Conventional Commits](https://www.conventionalcommits.org/):" msgstr "我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范:" -#: ../../development/contributing.md:42 3f9455efd72d41bea6cff4ed3b247382 +#: ../../development/contributing.md:80 ca8413d1fe18446ab77fc13c9df65611 msgid "Commit Types" msgstr "提交类型" -#: ../../development/contributing.md 1f7fa74052d54b4286fb2f4abb83206f +#: ../../development/contributing.md:33 24c3f38f6f054565b079dc5d18cd0e9a msgid "Type" msgstr "类型" -#: ../../development/contributing.md 6ae282657f44494c85acd96d1f675f9f +#: ../../development/contributing.md:33 09230b1a53de46f1ac1181534e171a29 msgid "Description" msgstr "描述" -#: ../../development/contributing.md 6ad4ca875ec949a596b205a071a4e42e +#: ../../development/contributing.md:33 1caacb78265d479d85132eef66817c15 msgid "`feat`" msgstr "`feat`" -#: ../../development/contributing.md cce5bb2486bf45a298706c655d433a1b +#: ../../development/contributing.md:33 ad510704985e46f2b9431d8fbfa7aab4 msgid "A new feature" msgstr "新功能" -#: ../../development/contributing.md 06db8bf4da1741b3a0f39741da8f95a9 +#: ../../development/contributing.md:33 305eaa6ec9f345e685d37f391a374f3c msgid "`fix`" msgstr "`fix`" -#: ../../development/contributing.md fb8a6dccda2d4e02b07e38a76fdab5da +#: ../../development/contributing.md:33 4c62176097ca4202921d0b968779f3bc msgid "A bug fix" msgstr "错误修复" -#: ../../development/contributing.md ad3bbbe3269740fe91c3cdbd1d150f57 +#: ../../development/contributing.md:33 a17c8da2a74f4e51b313af8b698e7371 msgid "`docs`" msgstr "`docs`" -#: ../../development/contributing.md e6a17bf5e31a4924811eb395d0a7475d +#: ../../development/contributing.md:33 ff9f1fac9bfd47f9a0d9f0cf1900f535 msgid "Documentation changes" msgstr "文档变更" -#: ../../development/contributing.md 03b97d9d84104540b6d1480a1215958c +#: ../../development/contributing.md:33 40077e95649c496e824206af157941ae msgid "`style`" msgstr "`style`" -#: ../../development/contributing.md 84d2220cf624404eb86631ce08a2e9b8 +#: ../../development/contributing.md:33 9f67d093ab2348bea5c15a10e626ea1c msgid "Code style changes (formatting, no logic change)" msgstr "代码风格变更(格式化,无逻辑变更)" -#: ../../development/contributing.md c3226dc2cd6f409cb5f35c72e8c932c6 +#: ../../development/contributing.md:33 0379c6f167df432591e7d423533a2259 msgid "`refactor`" msgstr "`refactor`" -#: ../../development/contributing.md 01270f202c344d79965d7e30e1716034 +#: ../../development/contributing.md:33 37a048dffab447c18e5222ce23447e5f msgid "Code refactoring (no feature or fix)" msgstr "代码重构(无新功能或修复)" -#: ../../development/contributing.md 59ec0ff0f6794c888c9e3cb915568a3c +#: ../../development/contributing.md:33 18fac2ba9ce1403ab44acf8c6b9fe880 msgid "`test`" msgstr "`test`" -#: ../../development/contributing.md 89d88989bbd044d58fb8c96d0cdcfc24 +#: ../../development/contributing.md:33 8a367a01aefe445aa65f2a8fced53a83 msgid "Adding or updating tests" msgstr "添加或更新测试" -#: ../../development/contributing.md 409c45c4b8bf42b4908ebffcd13c1c38 +#: ../../development/contributing.md:33 e7bd6b26fd0944b9a7da4f0b86d20ecc msgid "`chore`" msgstr "`chore`" -#: ../../development/contributing.md b0e2c6b8d07f4140815e92df524d1402 +#: ../../development/contributing.md:33 82570af5a33047d18146f4798ece953f msgid "Build process or auxiliary tool changes" msgstr "构建流程或辅助工具变更" -#: ../../development/contributing.md:54 71706ac533204322916664748cdf9634 +#: ../../development/contributing.md:92 400c917352974c818e879f49a7a080e7 msgid "Examples" msgstr "示例" -#: ../../development/contributing.md:62 804797447b194801892726af734ef1b3 +#: ../../development/contributing.md:100 0f3c64250f7240f2a5169bb628bb60d8 msgid "Pull Request Process" msgstr "Pull Request 流程" -#: ../../development/contributing.md:64 b63f337a634048dcaf43693bc92b8f84 +#: ../../development/contributing.md:102 69be032e100646d3b71a4ac295987abd msgid "**Create a branch** from `main` with a descriptive name" msgstr "从 `main` **创建分支**,使用描述性名称" -#: ../../development/contributing.md:65 293a78b97cf046dba61e235c59cc8aac +#: ../../development/contributing.md:103 97615e4734d542fc986a85e7ceddfd30 msgid "**Make your changes** and commit with conventional commit messages" msgstr "**进行更改**并使用规范的提交消息进行提交" -#: ../../development/contributing.md:66 07f298058d144421af22b41ce733a788 +#: ../../development/contributing.md:104 4fee7caa8e1244499a0bcf4dc7561d83 msgid "**Run checks** before submitting:" msgstr "提交之前**运行检查**:" -#: ../../development/contributing.md:72 f3331d7d54fb4ddb92ca3ea0b4d491dc +#: ../../development/contributing.md:110 d3764e1c20ea427d9bf5f77c9815750f msgid "**Push** your branch and open a Pull Request" msgstr "**推送**您的分支并打开 Pull Request" -#: ../../development/contributing.md:73 8aa0c1af678a4a93a4c469a637fe8e1b +#: ../../development/contributing.md:111 12a1f0a8c95a4e89ac67ebfd74bfc353 msgid "**Describe** your changes clearly in the PR description" msgstr "在 PR 描述中**清楚地描述**您的更改" -#: ../../development/contributing.md:74 cca3cf06e54f4fdab58c37e7426ab649 +#: ../../development/contributing.md:112 095fb831a7324f29be882485f8f5ac6d msgid "**Wait for review** — maintainers will review your code" msgstr "**等待审核** —— 维护者将审查您的代码" -#: ../../development/contributing.md:76 c4883b7a162e4456a979e28d054bd8d1 +#: ../../development/contributing.md:114 d2122e4b0ff04cc49f1670aab06c20a7 +msgid "" +"If your change updates contributor workflow, environment setup, or " +"developer-facing behavior, update the relevant pages under " +"`docs/development/` in the same PR." +msgstr "" +"如果您的变更更新了贡献者工作流、环境配置或面向开发者的行为,请在同一个 PR 中同步更新 " +"`docs/development/` 下对应的页面。" + +#: ../../development/contributing.md:116 e75889e00e2241c1a6fefa6eda4fe2e2 +msgid "" +"If your change updates agent or deep-research behavior, verify that tool " +"names, privilege boundaries, session persistence, and contributor docs " +"stay aligned." +msgstr "" +"如果您的变更更新了智能体或 deep-research 行为,请确认工具名称、权限边界、会话持久化与贡" +"献者文档保持一致。" + +#: ../../development/contributing.md:118 c1100bfb22e0415fa0a8dc6ab538fab6 msgid "PR Checklist" msgstr "PR 检查清单" -#: ../../development/contributing.md:78 e241e30c5fbb4e49a31ab341ed13dc7e +#: ../../development/contributing.md:120 3ec97acc6d254df28e9d01550e39ab08 msgid "Code follows the project's coding style" msgstr "代码遵循项目的编码风格" -#: ../../development/contributing.md:79 3b57d18e85ee4960acf04a704c077dd0 +#: ../../development/contributing.md:121 31bf69758a0741c89a578fde2d3fd525 msgid "Tests pass (`npm test`)" msgstr "测试通过(`npm test`)" -#: ../../development/contributing.md:80 792a05abb3d745eb99ea666bdc3a847d +#: ../../development/contributing.md:122 d42f813007eb4ca8a66e058f1375ba99 msgid "Lint passes (`npm run lint`)" msgstr "Lint 通过(`npm run lint`)" -#: ../../development/contributing.md:81 7225b7bfaeb449df99ebeb6cfbb7e814 -msgid "Build succeeds (`npm run build`)" -msgstr "构建成功(`npm run build`)" +#: ../../development/contributing.md:123 6928ece5a3e644a28aae75f42f715c15 +msgid "Build succeeds (`NEXT_TELEMETRY_DISABLED=1 npm run build`)" +msgstr "构建成功(`NEXT_TELEMETRY_DISABLED=1 npm run build`)" -#: ../../development/contributing.md:82 8bb9dd4f9f9a4a7b97709ff1506199ef +#: ../../development/contributing.md:124 9969450c8f414921b949db52bd48ac39 msgid "Documentation updated (if applicable)" msgstr "文档已更新(如适用)" -#: ../../development/contributing.md:84 b6aa5e3b06514fb19e290b3554509d12 +#: ../../development/contributing.md:126 9f6c30355d63407394363fa411b49510 msgid "Code of Conduct" msgstr "行为准则" -#: ../../development/contributing.md:86 f0da427d576a43f59d25050e3aa89aed +#: ../../development/contributing.md:128 da46ba6c17404c8b9cd88d4b52ede88a msgid "" "Please be respectful and constructive in all interactions. We aim to " "create a welcoming and inclusive community." msgstr "请在所有互动中保持尊重和建设性。我们致力于创建一个友好和包容的社区。" -#: ../../development/contributing.md:88 d22a4c47e83c40b5b68d3e96fa771f87 +#: ../../development/contributing.md:130 26a62030590e470e8b5685b2e7e2c819 msgid "Reporting Issues" msgstr "报告问题" -#: ../../development/contributing.md:90 e9bf47fa3e2f43e18771dc0444ccf697 +#: ../../development/contributing.md:132 c80947c4d73d46f091484bff499f8bc8 msgid "" -"Use [GitHub Issues](https://github.com/zjowowen/InnoClaw/issues) to " -"report bugs or request features (when enabled)" +"Use [GitHub Issues](https://github.com/SpectrAI-" +"Initiative/InnoClaw/issues) to report bugs or request features" msgstr "" -"使用 [GitHub Issues](https://github.com/zjowowen/InnoClaw/issues) " -"报告错误或请求功能(启用后可用)" +"使用 [GitHub Issues](https://github.com/SpectrAI-Initiative/InnoClaw/issues) 报告错误" +"或提出功能请求" -#: ../../development/contributing.md:91 e633e59a00b6442ca5036ad4921161d2 +#: ../../development/contributing.md:133 ced9a3d8917141d6bf5016bf49b733be msgid "Include clear steps to reproduce any bugs" msgstr "包含清晰的错误复现步骤" -#: ../../development/contributing.md:92 a314a95cd156486b8fabcaf0c6dca631 +#: ../../development/contributing.md:134 6ddefac02d8a42d7b2d3b9bc1a87f5c3 msgid "Provide environment details (OS, Node.js version, browser)" msgstr "提供环境详情(操作系统、Node.js 版本、浏览器)" diff --git a/docs/locales/zh_CN/LC_MESSAGES/development/repository-guidelines.po b/docs/locales/zh_CN/LC_MESSAGES/development/repository-guidelines.po new file mode 100644 index 00000000..0f716fd5 --- /dev/null +++ b/docs/locales/zh_CN/LC_MESSAGES/development/repository-guidelines.po @@ -0,0 +1,222 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) 2025, InnoClaw Contributors +# This file is distributed under the same license as the InnoClaw package. +# FIRST AUTHOR , 2026. +# +msgid "" +msgstr "" +"Project-Id-Version: InnoClaw \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-04-14 00:19+0800\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language: zh_CN\n" +"Language-Team: zh_CN \n" +"Plural-Forms: nplurals=1; plural=0;\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.18.0\n" + +#: ../../development/repository-guidelines.md:1 +#: 7073234a0c6149c9a75d9184f923d689 +msgid "Repository Guidelines" +msgstr "仓库规范" + +#: ../../development/repository-guidelines.md:3 +#: e10a5e4b3c6044d289fd71f6c23b4d57 +msgid "" +"This page is the documentation-site entry for repository development " +"workflow." +msgstr "本页是面向文档站读者的仓库开发流程入口。" + +#: ../../development/repository-guidelines.md:5 +#: c64d1c09e5d944d3a6de893918b0dc99 +msgid "" +"The canonical source of truth remains `AGENTS.md` at the repository root " +"in a local clone. Use this page when you are browsing the published docs " +"site, and use `AGENTS.md` when you are working inside the repository " +"itself." +msgstr "" +"在本地仓库克隆中,权威来源仍是仓库根目录的 `AGENTS.md`。当您浏览已发布的文档站时,请使用本页;当您在仓库内部工作时,请直接以 " +"`AGENTS.md` 为准。" + +#: ../../development/repository-guidelines.md:7 +#: 4806a8cf79d64d9ba43af8f751254407 +msgid "Source Of Truth" +msgstr "规范优先级" + +#: ../../development/repository-guidelines.md:9 +#: 06661d8b269c4485a3d2c68bcd33d7e8 +msgid "When instructions overlap, use this order:" +msgstr "当多份说明有重叠时,请按以下顺序处理:" + +#: ../../development/repository-guidelines.md:11 +#: 08573867a6484e1c91ef13f2fd43a5e9 +msgid "`AGENTS.md` at the repository root" +msgstr "仓库根目录中的 `AGENTS.md`" + +#: ../../development/repository-guidelines.md:12 +#: 5b12237165164d2fa2be387daaacb123 +msgid "`package.json` scripts and `docs/Makefile`" +msgstr "`package.json` 脚本与 `docs/Makefile`" + +#: ../../development/repository-guidelines.md:13 +#: 613957bfbe644a69862ffcdb916c7193 +msgid "`.github/workflows/*.yml`" +msgstr "`.github/workflows/*.yml`" + +#: ../../development/repository-guidelines.md:14 +#: b25ebda05a3943b482b1afdba2519b4c +msgid "Supporting pages under `docs/development/`" +msgstr "`docs/development/` 下的补充页面" + +#: ../../development/repository-guidelines.md:16 +#: a0433277f74543219b4b9ed4c3f838ce +msgid "What This Covers" +msgstr "本页涵盖" + +#: ../../development/repository-guidelines.md:18 +#: 87afb7f9ffed48f2a18a521d11f4b954 +msgid "Supported local environment" +msgstr "受支持的本地环境" + +#: ../../development/repository-guidelines.md:19 +#: fbc1c3fb31c54fa3afcac2a3d5104660 +msgid "Repository boundaries for code vs. scratch content" +msgstr "代码与临时内容的仓库边界" + +#: ../../development/repository-guidelines.md:20 +#: f12f174fbf4c460ebd579a01eb664b6c +msgid "Validation commands before review" +msgstr "提交评审前需要运行的验证命令" + +#: ../../development/repository-guidelines.md:21 +#: 8bd2141458db4b098c204694b0e74aa8 +msgid "Collaboration expectations in a shared worktree" +msgstr "共享工作树中的协作要求" + +#: ../../development/repository-guidelines.md:22 +#: 26bbeaf284e84ca3b9d7c89ae71562c0 +msgid "Database migration expectations" +msgstr "数据库迁移要求" + +#: ../../development/repository-guidelines.md:23 +#: aecc1cff90364402b6154f67a7654bca +msgid "API route responsibilities" +msgstr "API 路由职责" + +#: ../../development/repository-guidelines.md:24 +#: f9ac30448a9d4a888b89bd3fb5d554bb +msgid "Agent and deep-research development expectations" +msgstr "智能体与深度研究功能的开发要求" + +#: ../../development/repository-guidelines.md:25 +#: 6db4c61a43d84598b32f4c584ee6089f +msgid "Documentation update requirements" +msgstr "文档更新要求" + +#: ../../development/repository-guidelines.md:27 +#: 5859b0c6c85c4251bca7b3c8be56932d +msgid "Workflow Overview" +msgstr "流程总览" + +#: ../../development/repository-guidelines.md:29 +#: 85354b461b5940f1a94615e023993716 +msgid "English overview of the developer workflow from FigJam" +msgstr "来自 FigJam 的英文开发流程总览图" + +#: ../../development/repository-guidelines.md:34 +#: fdafc700c4bb42388fb7202e076cbaa5 +msgid "Chinese overview of the developer workflow from FigJam" +msgstr "来自 FigJam 的中文开发流程总览图" + +#: ../../development/repository-guidelines.md:39 +#: 276af624752d4be08f7b96ed35d1a9ad +msgid "" +"[English FigJam Board](https://www.figma.com/board/WFNaqCm92fh8ySjas6txi0" +"/InnoClaw-Developer-Workflow-Overview?node-id=0-1&p=f)" +msgstr "" +"[英文 FigJam 白板](https://www.figma.com/board/WFNaqCm92fh8ySjas6txi0/InnoClaw-" +"Developer-Workflow-Overview?node-id=0-1&p=f)" + +#: ../../development/repository-guidelines.md:40 +#: bb1b3758b028405f93840b2a8dda347b +#, python-format +msgid "" +"[Chinese FigJam " +"Board](https://www.figma.com/board/bSNAwMgaZmu4DXZisidzXx/InnoClaw-%E5%BC%80%E5%8F%91%E6%B5%81%E7%A8%8B%E6%A6%82%E8%A7%88" +"?node-id=0-1&p=f)" +msgstr "" +"[中文 FigJam " +"白板](https://www.figma.com/board/bSNAwMgaZmu4DXZisidzXx/InnoClaw-%E5%BC%80%E5%8F%91%E6%B5%81%E7%A8%8B%E6%A6%82%E8%A7%88" +"?node-id=0-1&p=f)" + +#: ../../development/repository-guidelines.md:42 +#: 141c59c7bf04477fa20d57cddac98fc1 +msgid "Which Guide To Read Next" +msgstr "接下来读哪一页" + +#: ../../development/repository-guidelines.md:44 +#: 6e2b4fc31cce43ec80dfac02b033b18f +msgid "" +"Read [Contributing](contributing.md) for the default branch, commit, and " +"PR workflow." +msgstr "阅读 [贡献指南](contributing.md),了解默认分支、提交与 PR 流程。" + +#: ../../development/repository-guidelines.md:45 +#: e438ba85813548ccb3df2b549dca1cfd +msgid "" +"Read [Collaboration](collaboration.md) before working in a dirty tree, " +"coordinating with another contributor, or handing work to automation." +msgstr "在脏工作树中工作、与其他贡献者协作,或将工作交给自动化工具之前,请先阅读 [协作规范](collaboration.md)。" + +#: ../../development/repository-guidelines.md:46 +#: a7c05472a91249e1aa877f858159a5db +msgid "" +"Read [Agent Development](agent-development.md) before changing prompts, " +"tools, streaming behavior, deep-research workflow logic, or other agent-" +"facing contracts." +msgstr "" +"在修改 prompts、tools、流式行为、deep-research 工作流逻辑,或其他面向智能体的契约之前,请先阅读 [智能体开发" +"](agent-development.md)。" + +#: ../../development/repository-guidelines.md:48 +#: bf378972ed2a425ca76c78c15202bd25 +msgid "Next Pages" +msgstr "后续页面" + +#: ../../development/repository-guidelines.md:50 +#: b047ce4ce1164c55a649655dd833c5e2 +msgid "[Contributing](contributing.md)" +msgstr "[贡献指南](contributing.md)" + +#: ../../development/repository-guidelines.md:51 +#: 0eae522193ac48e0bd09c76d1c1cac10 +msgid "[Collaboration](collaboration.md)" +msgstr "[协作规范](collaboration.md)" + +#: ../../development/repository-guidelines.md:52 +#: 4e1bb868bb464ad0bfda754062810f2c +msgid "[Project Structure](project-structure.md)" +msgstr "[项目结构](project-structure.md)" + +#: ../../development/repository-guidelines.md:53 +#: ead275e12fca449094d0fdfe5af2565b +msgid "[Local Development](local-development.md)" +msgstr "[本地开发](local-development.md)" + +#: ../../development/repository-guidelines.md:54 +#: df46707e349d4e4f9c534d114968781a +msgid "[Testing](testing.md)" +msgstr "[测试](testing.md)" + +#: ../../development/repository-guidelines.md:55 +#: ddca2adef50f48dc910a99c3b64b0a7f +msgid "[Agent Development](agent-development.md)" +msgstr "[智能体开发](agent-development.md)" + +#: ../../development/repository-guidelines.md:56 +#: 352ba70d1fac4209b993e6cc6acb4a0d +msgid "[Documentation Development](documentation.md)" +msgstr "[文档开发](documentation.md)" diff --git a/docs/troubleshooting/faq.md b/docs/troubleshooting/faq.md index 9c23f073..fa55b625 100644 --- a/docs/troubleshooting/faq.md +++ b/docs/troubleshooting/faq.md @@ -115,10 +115,10 @@ SQLite requires a local filesystem for reliable locking. If the project is on NF DATABASE_URL=/tmp/innoclaw/innoclaw.db ``` -Also set `NEXT_BUILD_DIR` to avoid Turbopack cache errors: +InnoClaw now disables Next's dist-dir lock automatically on lockless network filesystems, so this startup error no longer requires a special `NEXT_BUILD_DIR`. If you still want a different build directory, keep it inside the repo: ```ini -NEXT_BUILD_DIR=/tmp/innoclaw-next +NEXT_BUILD_DIR=.next-local ``` ### How do I submit K8s / GPU jobs? diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index 0d089a31..f430eff4 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -128,12 +128,14 @@ npx drizzle-kit migrate ## Build Configuration -If the project resides on a network filesystem (NFS, CIFS, etc.), the Next.js build cache may fail. Set a local build directory: +If the project resides on a network filesystem (NFS, CIFS, etc.) or another mount without local file locking, InnoClaw automatically disables Next's dist-dir lock so `npm run dev` can start. `NEXT_BUILD_DIR` is still available if you want an alternate build directory inside the repo: ```ini -NEXT_BUILD_DIR=/tmp/innoclaw-next +NEXT_BUILD_DIR=.next-local ``` +`NEXT_BUILD_DIR` must stay within the project root on Next.js 16 / Turbopack. Absolute paths such as `/tmp/innoclaw-next` are not valid here. + ## Proxy Configuration For environments that require an HTTP proxy to reach external APIs: diff --git a/drizzle.config.ts b/drizzle.config.ts index 6ac5a6f5..54f44c51 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,4 +1,3 @@ -import { defineConfig } from "drizzle-kit"; import fs from "fs"; // Load .env.local so DATABASE_URL is available when running `npx drizzle-kit migrate`. @@ -18,11 +17,13 @@ try { // .env.local may not exist; that's fine – DATABASE_URL will use the default. } -export default defineConfig({ +const config = { schema: "./src/lib/db/schema.ts", out: "./drizzle", dialect: "sqlite", dbCredentials: { url: process.env.DATABASE_URL || "./data/innoclaw.db", }, -}); +}; + +export default config; diff --git a/drizzle/0016_auth_users.sql b/drizzle/0016_auth_users.sql new file mode 100644 index 00000000..a7020fe9 --- /dev/null +++ b/drizzle/0016_auth_users.sql @@ -0,0 +1,47 @@ +-- Local authentication and user ownership + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + is_active INTEGER NOT NULL DEFAULT 1, + last_login_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique_idx ON users(email); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS user_sessions_token_hash_unique_idx ON user_sessions(token_hash); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS user_sessions_user_idx ON user_sessions(user_id); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS user_sessions_expires_idx ON user_sessions(expires_at); +--> statement-breakpoint +ALTER TABLE workspaces ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS workspaces_owner_user_idx ON workspaces(owner_user_id); +--> statement-breakpoint +ALTER TABLE skills ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS skills_owner_user_idx ON skills(owner_user_id); +--> statement-breakpoint +ALTER TABLE scheduled_tasks ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS scheduled_tasks_owner_user_idx ON scheduled_tasks(owner_user_id); +--> statement-breakpoint +ALTER TABLE hf_datasets ADD COLUMN owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS hf_datasets_owner_user_idx ON hf_datasets(owner_user_id); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f4a79c1a..1aa9e706 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1774000000000, "tag": "0015_remote_profile_binding", "breakpoints": true + }, + { + "idx": 17, + "version": "6", + "when": 1774086400000, + "tag": "0016_auth_users", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/logs/dev.log b/logs/dev.log new file mode 100644 index 00000000..e6b10f5f --- /dev/null +++ b/logs/dev.log @@ -0,0 +1,19 @@ + +> innoclaw@0.2.0 predev +> node scripts/check-deps.js + + +> innoclaw@0.2.0 dev +> next dev + +⨯ Failed to start server +Error: listen EPERM: operation not permitted 0.0.0.0:3000 + at (Error: listen EPERM: operation not permitted 0.0.0.0:3000) + at new Promise () { + code: 'EPERM', + errno: -1, + syscall: 'listen', + address: '0.0.0.0', + port: 3000 +} +[?25h diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 00000000..5be98c3e --- /dev/null +++ b/middleware.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + AUTH_PUBLIC_API_PREFIXES, + AUTH_PUBLIC_PATHS, + AUTH_SESSION_COOKIE, + AUTH_SESSION_EXPIRES_COOKIE, + AUTH_SESSION_SIGNATURE_COOKIE, +} from "@/lib/auth/constants"; + +function getSigningSecret(): string { + return ( + process.env.AUTH_SECRET || + process.env.NEXTAUTH_SECRET || + "innoclaw-development-secret" + ); +} + +function base64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +async function signToken(token: string): Promise { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(getSigningSecret()), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + "HMAC", + key, + new TextEncoder().encode(token), + ); + return base64Url(signature); +} + +async function hasValidSessionMarker(request: NextRequest): Promise { + const token = request.cookies.get(AUTH_SESSION_COOKIE)?.value; + const signature = request.cookies.get(AUTH_SESSION_SIGNATURE_COOKIE)?.value; + const expiresAt = request.cookies.get(AUTH_SESSION_EXPIRES_COOKIE)?.value; + + if (!token || !signature || !expiresAt) { + return false; + } + + if (Number.isNaN(Date.parse(expiresAt)) || new Date(expiresAt).getTime() <= Date.now()) { + return false; + } + + return (await signToken(token)) === signature; +} + +function isPublicPath(pathname: string): boolean { + if (AUTH_PUBLIC_PATHS.has(pathname)) { + return true; + } + + return ( + pathname.startsWith("/_next/") || + pathname.startsWith("/favicon") || + pathname.includes(".") + ); +} + +function isPublicApi(pathname: string): boolean { + return AUTH_PUBLIC_API_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (isPublicPath(pathname) || isPublicApi(pathname)) { + if (AUTH_PUBLIC_PATHS.has(pathname) && await hasValidSessionMarker(request)) { + return NextResponse.redirect(new URL("/", request.url)); + } + return NextResponse.next(); + } + + const hasSession = await hasValidSessionMarker(request); + if (hasSession) { + return NextResponse.next(); + } + + if (pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("next", pathname); + return NextResponse.redirect(loginUrl); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image).*)"], +}; diff --git a/next.config.ts b/next.config.ts index 147201bc..c95c3a90 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,8 +1,28 @@ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; +import { + assessDistDirLocking, + resolveNextBuildDir, +} from "./src/lib/dev/project-filesystem"; + const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts"); +const projectDir = __dirname; +const resolvedBuildDir = resolveNextBuildDir(projectDir, process.env.NEXT_BUILD_DIR); +const distDirLocking = assessDistDirLocking(projectDir, resolvedBuildDir.distDir); + +if (resolvedBuildDir.warning) { + console.warn(`[next.config] ${resolvedBuildDir.warning}`); +} + +if (distDirLocking.disableLock) { + const mountPoint = distDirLocking.mount?.mountPoint ?? projectDir; + console.warn( + `[next.config] Detected ${distDirLocking.reason} at ${mountPoint}; disabling Next dist-dir locking so dev/build can run on this mount. SQLite should still use a local DATABASE_URL on network filesystems.` + ); +} + const securityHeaders = [ { key: "X-Content-Type-Options", value: "nosniff" }, { key: "X-Frame-Options", value: "SAMEORIGIN" }, @@ -20,12 +40,18 @@ const serverExternalPackages = [ const nextConfig: NextConfig = { output: "standalone", serverExternalPackages, - // Allow overriding the .next build directory via env var. - // Useful on network/shared filesystems where Turbopack cache persistence fails. - ...(process.env.NEXT_BUILD_DIR ? { distDir: process.env.NEXT_BUILD_DIR } : {}), + // Keep distDir inside the project root so Turbopack accepts it. + ...(resolvedBuildDir.distDir ? { distDir: resolvedBuildDir.distDir } : {}), + ...(distDirLocking.disableLock + ? { + experimental: { + lockDistDir: false, + }, + } + : {}), turbopack: { // Set the project root explicitly to avoid lockfile detection issues. - root: __dirname, + root: projectDir, }, async headers() { return [ diff --git a/package-lock.json b/package-lock.json index e1d90fa6..819efb61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,19 +9,19 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/anthropic": "^3.0.64", - "@ai-sdk/openai": "^3.0.49", - "@ai-sdk/react": "^3.0.144", + "@ai-sdk/anthropic": "^3.0.76", + "@ai-sdk/openai": "^3.0.63", + "@ai-sdk/react": "^3.0.179", "@codemirror/language": "^6.12.3", "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.41.0", - "@huggingface/hub": "^2.11.0", - "@larksuiteoapi/node-sdk": "^1.60.0", + "@codemirror/view": "^6.42.1", + "@huggingface/hub": "^2.11.2", + "@larksuiteoapi/node-sdk": "^1.63.1", "@uiw/react-codemirror": "^4.25.9", "@xyflow/react": "^12.10.2", "ai": "^6.0.99", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.9.0", "cheerio": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -29,19 +29,19 @@ "dagre": "^0.8.5", "drizzle-orm": "^0.45.2", "highlight.js": "^11.11.1", - "katex": "^0.16.44", - "lucide-react": "^1.7.0", + "katex": "^0.16.45", + "lucide-react": "^1.14.0", "minimatch": "^10.2.5", - "nanoid": "^5.1.7", - "next": "16.2.2", - "next-intl": "^4.9.0", + "nanoid": "^5.1.11", + "next": "16.2.6", + "next-intl": "^4.11.1", "next-themes": "^0.4.6", "pdf-parse": "^2.4.5", "radix-ui": "^1.4.3", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.6", + "react-dom": "19.2.6", "react-markdown": "^10.1.0", - "react-resizable-panels": "^4.8.0", + "react-resizable-panels": "^4.11.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", "rehype-sanitize": "^6.0.0", @@ -52,10 +52,13 @@ "remark-rehype": "^11.1.2", "sonner": "^2.0.7", "swr": "^2.4.1", - "tailwind-merge": "^3.5.0", - "three": "^0.183.2", + "tailwind-merge": "^3.6.0", + "three": "^0.184.0", "unified": "^11.0.5", - "zod": "^4.3.6" + "zod": "^4.4.3" + }, + "bin": { + "innoclaw": "plugins/innoclaw-cli/scripts/innoclaw-cli.mjs" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -65,15 +68,15 @@ "@types/pdf-parse": "^1.1.5", "@types/react": "^19", "@types/react-dom": "^19", - "@types/three": "^0.183.1", + "@types/three": "^0.184.1", "drizzle-kit": "^0.31.10", "eslint": "^9", - "eslint-config-next": "16.2.2", - "shadcn": "^4.1.2", + "eslint-config-next": "16.2.6", + "shadcn": "^4.7.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "6.0.2", - "vitest": "^4.1.2" + "vitest": "^4.1.5" }, "engines": { "node": ">=24.0.0" @@ -83,13 +86,13 @@ } }, "node_modules/@ai-sdk/anthropic": { - "version": "3.0.64", - "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.64.tgz", - "integrity": "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g==", + "version": "3.0.76", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-3.0.76.tgz", + "integrity": "sha512-kOuvT9e6PygFvgYpkr4v9gjvmcMPfJp79jaXjeRl9Gpoj2OXdtc3ero7o1ic+tiSBw5IMubxXFO68BCA/axGJA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.21" + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" }, "engines": { "node": ">=18" @@ -99,14 +102,14 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "3.0.84", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.84.tgz", - "integrity": "sha512-RnUw6UNvkaw9MEaJU9cIjA+WBP+ZR5+M/9nfbfJHcGKtTbcWXijJuYKx9nYRnm+qU+iiakb0XvQA/vvho6lTsw==", + "version": "3.0.112", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.112.tgz", + "integrity": "sha512-jiBao9pR4owWyjo0BnuNc7WSQBGOD0thysE4AFgZXaG+zMFbISQXUkJr7ePw/phBvePy7jE5FSA2Lf7lwqUiiQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.21", - "@vercel/oidc": "3.1.0" + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", + "@vercel/oidc": "3.2.0" }, "engines": { "node": ">=18" @@ -116,13 +119,13 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "3.0.49", - "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.49.tgz", - "integrity": "sha512-U2f0pCyNn/jQH3wjgxr8o9VvCkuDFTtXbIhbFFtgXqCzMbed6rBnvzQcAMEK0/Pa44byL9zfcvCOFOflvkRA8w==", + "version": "3.0.63", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.63.tgz", + "integrity": "sha512-4yY/m8a57MNNVoJCsXuNblKf6BO4yuAuLKRX4tzSNffBEBSp1FlcWdPE0Z4FkqUeS0AJhYSSqp0GIiA/cIcDNA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.21" + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27" }, "engines": { "node": ">=18" @@ -132,9 +135,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", - "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.10.tgz", + "integrity": "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw==", "license": "Apache-2.0", "dependencies": { "json-schema": "^0.4.0" @@ -144,14 +147,14 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "4.0.21", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.21.tgz", - "integrity": "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==", + "version": "4.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.27.tgz", + "integrity": "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", - "eventsource-parser": "^3.0.6" + "eventsource-parser": "^3.0.8" }, "engines": { "node": ">=18" @@ -161,13 +164,13 @@ } }, "node_modules/@ai-sdk/react": { - "version": "3.0.144", - "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.144.tgz", - "integrity": "sha512-8Y7OX1BhEwEWr4c9sy9UoX/bpBw6TvCvO5XXiqYpM8l5QjzIJlKDs+7QJEJmo9oobbx+Z2Ops3OjT+4r7JJUzw==", + "version": "3.0.179", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.179.tgz", + "integrity": "sha512-wq3gqDrwi6QvwHQuVrTpjeUQI/LHZ5ysokWTIS1hOFw0UIIX8eVpri5iVvqQuq5CeQw/6bYXSI15SfMtZJixpQ==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider-utils": "4.0.21", - "ai": "6.0.142", + "@ai-sdk/provider-utils": "4.0.27", + "ai": "6.0.177", "swr": "^2.2.5", "throttleit": "2.1.0" }, @@ -1032,9 +1035,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.41.0", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz", - "integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==", + "version": "6.42.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz", + "integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.6.0", @@ -1270,21 +1273,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -1292,9 +1295,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -2424,55 +2427,34 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@formatjs/bigdecimal": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@formatjs/bigdecimal/-/bigdecimal-0.2.0.tgz", - "integrity": "sha512-GeaxHZbUoYvHL9tC5eltHLs+1zU70aPw0s7LwqgktIzF5oMhNY4o4deEtusJMsq7WFJF3Ye2zQEzdG8beVk73w==", - "license": "MIT" - }, - "node_modules/@formatjs/ecma402-abstract": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.2.0.tgz", - "integrity": "sha512-dHnqHgBo6GXYGRsepaE1wmsC2etaivOWd5VaJstZd+HI2zR3DCUjbDVZRtoPGkkXZmyHvBwrdEUuqfvzhF/DtQ==", - "license": "MIT", - "dependencies": { - "@formatjs/bigdecimal": "0.2.0", - "@formatjs/fast-memoize": "3.1.1", - "@formatjs/intl-localematcher": "0.8.2" - } - }, "node_modules/@formatjs/fast-memoize": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz", - "integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.4.tgz", + "integrity": "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA==", "license": "MIT" }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.3.tgz", - "integrity": "sha512-HJWZ9S6JWey6iY5+YXE3Kd0ofWU1sC2KTTp56e1168g/xxWvVvr8k9G4fexIgwYV9wbtjY7kGYK5FjoWB3B2OQ==", + "version": "3.5.7", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.7.tgz", + "integrity": "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "3.2.0", - "@formatjs/icu-skeleton-parser": "2.1.3" + "@formatjs/icu-skeleton-parser": "2.1.7" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.3.tgz", - "integrity": "sha512-9mFp8TJ166ZM2pcjKwsBWXrDnOJGT7vMEScVgLygUODPOsE8S6f/FHoacvrlHK1B4dYZk8vSCNruyPU64AfgJQ==", - "license": "MIT", - "dependencies": { - "@formatjs/ecma402-abstract": "3.2.0" - } + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.7.tgz", + "integrity": "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA==", + "license": "MIT" }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz", - "integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==", + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.6.tgz", + "integrity": "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ==", "license": "MIT", "dependencies": { - "@formatjs/fast-memoize": "3.1.1" + "@formatjs/fast-memoize": "3.1.4" } }, "node_modules/@hono/node-server": { @@ -2488,13 +2470,20 @@ "hono": "^4" } }, + "node_modules/@huggingface/blake3-jit": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@huggingface/blake3-jit/-/blake3-jit-0.0.2.tgz", + "integrity": "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w==", + "license": "MIT" + }, "node_modules/@huggingface/hub": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.11.0.tgz", - "integrity": "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ==", + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.11.2.tgz", + "integrity": "sha512-t6T8lO+gCImwCrcs8i5ZwX8Wl0pFMnwmuthTRDpu3EBL/wcjauPNyhQ8qKGz5fcp+An4zaJGol/UFnJoPlMCew==", "license": "MIT", "dependencies": { - "@huggingface/tasks": "^0.19.90" + "@huggingface/tasks": "^0.20.24", + "@huggingface/xetchunk-wasm": "^0.0.6" }, "bin": { "hfjs": "dist/cli.js" @@ -2507,11 +2496,21 @@ } }, "node_modules/@huggingface/tasks": { - "version": "0.19.90", - "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.19.90.tgz", - "integrity": "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA==", + "version": "0.20.24", + "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.20.24.tgz", + "integrity": "sha512-RFfF/xx2Mrz9fW6IXPRdb2oy11DpvGZ6SQPO8BOGutsBec0l2bVvFIsXDs5Hn+TCNvPBMjTDaYq9DHnPNMMa6g==", "license": "MIT" }, + "node_modules/@huggingface/xetchunk-wasm": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@huggingface/xetchunk-wasm/-/xetchunk-wasm-0.0.6.tgz", + "integrity": "sha512-LoYPl7jvUvOnkysrhMjAeBzK5mVe2GFu8O3IsZb1w7l7v+NqjDsyRduwct3yraPAGmzTxxdb/2aIORL98Y949g==", + "license": "MIT", + "dependencies": { + "@huggingface/blake3-jit": "0.0.2", + "gearhash-jit": "1.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3169,9 +3168,9 @@ } }, "node_modules/@larksuiteoapi/node-sdk": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.60.0.tgz", - "integrity": "sha512-MS1eXx7K6HHIyIcCBkJLb21okoa8ZatUGQWZaCCUePm6a37RWFmT6ZKlKvHxAanSX26wNuNlwP0RhgscsE+T6g==", + "version": "1.63.1", + "resolved": "https://registry.npmjs.org/@larksuiteoapi/node-sdk/-/node-sdk-1.63.1.tgz", + "integrity": "sha512-bVC2QVkITZ1i6kLP7hI7DXtp61ic9shP/F+bp/2qZ0ISSvrcHp2euu1xt6C29jPJVNieRgvdsBPuapOlybviVw==", "license": "MIT", "dependencies": { "axios": "~1.13.3", @@ -3647,15 +3646,15 @@ } }, "node_modules/@next/env": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", - "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", + "integrity": "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz", - "integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.6.tgz", + "integrity": "sha512-Z8l6o4JWKUl755x4R+wogD86KPeU+Ckw4K+SYG4kHeOJtRenDeK+OSbGcqZpDtbwn9DsJVdir2UxmwXuinUbUw==", "dev": true, "license": "MIT", "dependencies": { @@ -3663,9 +3662,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", - "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.6.tgz", + "integrity": "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==", "cpu": [ "arm64" ], @@ -3679,9 +3678,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", - "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.6.tgz", + "integrity": "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==", "cpu": [ "x64" ], @@ -3695,9 +3694,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", - "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.6.tgz", + "integrity": "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==", "cpu": [ "arm64" ], @@ -3711,9 +3710,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", - "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.6.tgz", + "integrity": "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==", "cpu": [ "arm64" ], @@ -3727,9 +3726,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", - "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.6.tgz", + "integrity": "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==", "cpu": [ "x64" ], @@ -3743,9 +3742,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", - "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.6.tgz", + "integrity": "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==", "cpu": [ "x64" ], @@ -3759,9 +3758,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", - "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.6.tgz", + "integrity": "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==", "cpu": [ "arm64" ], @@ -3775,9 +3774,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", - "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.6.tgz", + "integrity": "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==", "cpu": [ "x64" ], @@ -3915,9 +3914,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.128.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", "dev": true, "license": "MIT", "funding": { @@ -5794,9 +5793,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", "cpu": [ "arm64" ], @@ -5811,9 +5810,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", "cpu": [ "arm64" ], @@ -5828,9 +5827,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", "cpu": [ "x64" ], @@ -5845,9 +5844,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", "cpu": [ "x64" ], @@ -5862,9 +5861,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", "cpu": [ "arm" ], @@ -5879,9 +5878,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", "cpu": [ "arm64" ], @@ -5896,9 +5895,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", "cpu": [ "arm64" ], @@ -5913,9 +5912,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", "cpu": [ "ppc64" ], @@ -5930,9 +5929,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", "cpu": [ "s390x" ], @@ -5947,9 +5946,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", "cpu": [ "x64" ], @@ -5964,9 +5963,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", "cpu": [ "x64" ], @@ -5981,9 +5980,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", "cpu": [ "arm64" ], @@ -5998,9 +5997,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", "cpu": [ "wasm32" ], @@ -6008,16 +6007,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -6034,9 +6035,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", "cpu": [ "arm64" ], @@ -6051,9 +6052,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", "cpu": [ "x64" ], @@ -6068,9 +6069,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", "dev": true, "license": "MIT" }, @@ -6298,49 +6299,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -6355,9 +6356,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -6372,9 +6373,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -6389,9 +6390,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -6406,9 +6407,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -6423,9 +6424,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -6440,9 +6441,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -6457,9 +6458,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -6474,9 +6475,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -6491,9 +6492,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -6509,10 +6510,10 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -6521,18 +6522,18 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.8.1", + "version": "1.10.0", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.8.1", + "version": "1.10.0", "dev": true, "inBundle": true, "license": "MIT", @@ -6542,7 +6543,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", + "version": "1.2.1", "dev": true, "inBundle": true, "license": "MIT", @@ -6552,19 +6553,21 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", + "version": "1.1.4", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { @@ -6585,9 +6588,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -6602,9 +6605,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -6619,17 +6622,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", - "postcss": "^8.5.6", - "tailwindcss": "4.2.2" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" } }, "node_modules/@ts-morph/common": { @@ -6845,12 +6848,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", + "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/pdf-parse": { @@ -6897,9 +6900,9 @@ "license": "MIT" }, "node_modules/@types/three": { - "version": "0.183.1", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", - "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", "dev": true, "license": "MIT", "dependencies": { @@ -6907,9 +6910,8 @@ "@tweenjs/tween.js": "~23.1.3", "@types/stats.js": "*", "@types/webxr": ">=0.5.17", - "@webgpu/types": "*", "fflate": "~0.8.2", - "meshoptimizer": "~1.0.1" + "meshoptimizer": "~1.1.1" } }, "node_modules/@types/unist": { @@ -7517,25 +7519,25 @@ ] }, "node_modules/@vercel/oidc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", - "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", "license": "Apache-2.0", "engines": { "node": ">= 20" } }, "node_modules/@vitest/expect": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", - "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -7544,9 +7546,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", - "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { @@ -7557,13 +7559,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", - "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.2", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -7571,14 +7573,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", - "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -7587,9 +7589,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", - "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -7597,13 +7599,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", - "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.2", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -7611,13 +7613,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@webgpu/types": { - "version": "0.1.69", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", - "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@xyflow/react": { "version": "12.10.2", "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", @@ -7698,14 +7693,14 @@ } }, "node_modules/ai": { - "version": "6.0.142", - "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.142.tgz", - "integrity": "sha512-ZoxAsnTL/dFg5WdcwC8QNhKVlLtqwwT3I7p/4i8IJJP+6ZwqF1ljuwMsAsPYYvppZ+RzUxjxxFGb1cbEhNH3dg==", + "version": "6.0.177", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.177.tgz", + "integrity": "sha512-1xQtbeWwNcLyyM86ixZhkKvT+WRXc1lvarIKqPVtsyn8F9NDikwUMBqYu+aQKDgMht50SMXh4qboYuU8MeHZZA==", "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "3.0.84", - "@ai-sdk/provider": "3.0.8", - "@ai-sdk/provider-utils": "4.0.21", + "@ai-sdk/gateway": "3.0.112", + "@ai-sdk/provider": "3.0.10", + "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "1.9.0" }, "engines": { @@ -8137,9 +8132,9 @@ } }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -9737,14 +9732,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -10095,13 +10090,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz", - "integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.6.tgz", + "integrity": "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q==", "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.2.2", + "@next/eslint-plugin-next": "16.2.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -10646,9 +10641,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -11175,6 +11170,12 @@ "dev": true, "license": "MIT" }, + "node_modules/gearhash-jit": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/gearhash-jit/-/gearhash-jit-1.0.2.tgz", + "integrity": "sha512-UhzJL4KXSdqAKepy/tZwmi2Rcy0YMmtiC4DQS4SURCuIWdh8ECZtnXK2ePRMLigfB61hRKdLK/Vgg2bSw73izQ==", + "license": "MIT" + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -11866,9 +11867,9 @@ } }, "node_modules/icu-minify": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.9.0.tgz", - "integrity": "sha512-9ev7MqkN29jcIelUAqJRfNCxzGOEkBJPnr+scYATMp2bfpU4Bm1eIwYU0/o5xRy8BBnSWMUjK58WTB3132P0bg==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.11.1.tgz", + "integrity": "sha512-C0tsPVuvyNp+++qWJP+mty/KLLStjerOZqu3W1xWLJkChEDbDi9Taoj6blK7L/onxbuVzwgH6k9Sf+rOV6lOvw==", "funding": [ { "type": "individual", @@ -11971,14 +11972,13 @@ } }, "node_modules/intl-messageformat": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.0.tgz", - "integrity": "sha512-IhghAA8n4KSlXuWKzYsWyWb82JoYTzShfyvdSF85oJPnNOjvv4kAo7S7Jtkm3/vJ53C7dQNRO+Gpnj3iWgTjBQ==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.4.tgz", + "integrity": "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "3.2.0", - "@formatjs/fast-memoize": "3.1.1", - "@formatjs/icu-messageformat-parser": "3.5.3" + "@formatjs/fast-memoize": "3.1.4", + "@formatjs/icu-messageformat-parser": "3.5.7" } }, "node_modules/ip-address": { @@ -12799,9 +12799,9 @@ } }, "node_modules/katex": { - "version": "0.16.44", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", - "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -13283,9 +13283,9 @@ } }, "node_modules/lucide-react": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", - "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -13671,9 +13671,9 @@ } }, "node_modules/meshoptimizer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", - "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", "dev": true, "license": "MIT" }, @@ -14441,9 +14441,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.7.tgz", - "integrity": "sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", "funding": [ { "type": "github", @@ -14497,12 +14497,12 @@ } }, "node_modules/next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", - "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", + "version": "16.2.6", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz", + "integrity": "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==", "license": "MIT", "dependencies": { - "@next/env": "16.2.2", + "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -14516,14 +14516,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.2", - "@next/swc-darwin-x64": "16.2.2", - "@next/swc-linux-arm64-gnu": "16.2.2", - "@next/swc-linux-arm64-musl": "16.2.2", - "@next/swc-linux-x64-gnu": "16.2.2", - "@next/swc-linux-x64-musl": "16.2.2", - "@next/swc-win32-arm64-msvc": "16.2.2", - "@next/swc-win32-x64-msvc": "16.2.2", + "@next/swc-darwin-arm64": "16.2.6", + "@next/swc-darwin-x64": "16.2.6", + "@next/swc-linux-arm64-gnu": "16.2.6", + "@next/swc-linux-arm64-musl": "16.2.6", + "@next/swc-linux-x64-gnu": "16.2.6", + "@next/swc-linux-x64-musl": "16.2.6", + "@next/swc-win32-arm64-msvc": "16.2.6", + "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { @@ -14550,9 +14550,9 @@ } }, "node_modules/next-intl": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.9.0.tgz", - "integrity": "sha512-MMNAjewHUw9Ke93E5/Yzhf8lqesesaXJTPlrK3FwECgn4EXG9m7Tuzy4rnDes0ogjDhQIa/Ksj/qmFnHJAOluw==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.11.1.tgz", + "integrity": "sha512-s32lFFLXkxrW6fy+4IVaGD5J8xPpbEDFLfBbXV73CTbHAGhOGMjYN4/rftdsKOQ44AnPhnZ5Et+ZNMr5tRpsqA==", "funding": [ { "type": "individual", @@ -14564,11 +14564,11 @@ "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", - "icu-minify": "^4.9.0", + "icu-minify": "^4.11.1", "negotiator": "^1.0.0", - "next-intl-swc-plugin-extractor": "^4.9.0", + "next-intl-swc-plugin-extractor": "^4.11.1", "po-parser": "^2.1.1", - "use-intl": "^4.9.0" + "use-intl": "^4.11.1" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", @@ -14581,9 +14581,9 @@ } }, "node_modules/next-intl-swc-plugin-extractor": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.9.0.tgz", - "integrity": "sha512-CAu6Qy6XiCenKsvzyCPm2cZFkGfcvhJi8N93TCnOowmzD4Br3ked7QdROusRRp4MQ1iG9u+KCLgVcM9CLDUOIQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.11.1.tgz", + "integrity": "sha512-jHKGij7NoYccy2y54+e/wHVMoRgNt4h/Kn0XS9c4GbKu3KgJyANLUN8sFcDixv6sqz4V2kh6CTWgrkIidQksUg==", "license": "MIT" }, "node_modules/next-intl/node_modules/@swc/core": { @@ -15387,9 +15387,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -15788,24 +15788,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.6" } }, "node_modules/react-is": { @@ -15890,9 +15890,9 @@ } }, "node_modules/react-resizable-panels": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.8.0.tgz", - "integrity": "sha512-2uEABkewb3ky/ZgIlAUxWa1W/LjsK494fdV1QsXxst7CDRHCzo7h22tWWu3NNaBjmiuriOCt3CvhipnaYcpoIw==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.0.tgz", + "integrity": "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw==", "license": "MIT", "peerDependencies": { "react": "^18.0.0 || ^19.0.0", @@ -16240,14 +16240,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" }, "bin": { "rolldown": "bin/cli.mjs" @@ -16256,21 +16256,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" } }, "node_modules/router": { @@ -16539,9 +16539,9 @@ "license": "ISC" }, "node_modules/shadcn": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.1.2.tgz", - "integrity": "sha512-qNQcCavkbYsgBj+X09tF2bTcwRd8abR880bsFkDU2kMqceMCLAm5c+cLg7kWDhfh1H9g08knpQ5ZEf6y/co16g==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.7.0.tgz", + "integrity": "sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w==", "dev": true, "license": "MIT", "dependencies": { @@ -17310,9 +17310,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", - "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", "license": "MIT", "funding": { "type": "github", @@ -17320,16 +17320,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -17369,9 +17369,9 @@ } }, "node_modules/three": { - "version": "0.183.2", - "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", - "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", "license": "MIT" }, "node_modules/throttleit": { @@ -17411,14 +17411,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -17446,9 +17446,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -18315,9 +18315,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, "node_modules/unicorn-magic": { @@ -18576,9 +18576,9 @@ } }, "node_modules/use-intl": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.9.0.tgz", - "integrity": "sha512-GehJvP7gu8SvmaDHNDNrRHt2TCNSZt4l1cGJMpUX77TGeZPAQKVQokAVvoYkeTT1UWPtv9RJ6N16UJNButzrgg==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.11.1.tgz", + "integrity": "sha512-/dqWSqUSbVMzC+fdy7io8enhGYHeGeHK1bFhTLrp0ZblqdzY4FkE+tkffW6IfCauqaIA2/z4DQae4XEn93+raw==", "funding": [ { "type": "individual", @@ -18589,7 +18589,7 @@ "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", - "icu-minify": "^4.9.0", + "icu-minify": "^4.11.1", "intl-messageformat": "^11.1.0" }, "peerDependencies": { @@ -18696,19 +18696,19 @@ } }, "node_modules/vitest": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", - "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.2", - "@vitest/mocker": "4.1.2", - "@vitest/pretty-format": "4.1.2", - "@vitest/runner": "4.1.2", - "@vitest/snapshot": "4.1.2", - "@vitest/spy": "4.1.2", - "@vitest/utils": "4.1.2", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -18736,10 +18736,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.2", - "@vitest/browser-preview": "4.1.2", - "@vitest/browser-webdriverio": "4.1.2", - "@vitest/ui": "4.1.2", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -18763,6 +18765,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -18778,13 +18786,13 @@ } }, "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", - "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.2", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -18818,17 +18826,17 @@ } }, "node_modules/vitest/node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", - "tinyglobby": "^0.2.15" + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -18844,8 +18852,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -19322,9 +19330,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index ff238ecd..42bb905a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ ], "author": "InnoClaw Contributors", "license": "Apache-2.0", + "bin": { + "innoclaw": "./plugins/innoclaw-cli/scripts/innoclaw-cli.mjs" + }, "repository": { "type": "git", "url": "https://github.com/SpectrAI-Initiative/InnoClaw.git" @@ -35,19 +38,19 @@ "test": "vitest run" }, "dependencies": { - "@ai-sdk/anthropic": "^3.0.64", - "@ai-sdk/openai": "^3.0.49", - "@ai-sdk/react": "^3.0.144", + "@ai-sdk/anthropic": "^3.0.76", + "@ai-sdk/openai": "^3.0.63", + "@ai-sdk/react": "^3.0.179", "@codemirror/language": "^6.12.3", "@codemirror/language-data": "^6.5.2", "@codemirror/state": "^6.6.0", - "@codemirror/view": "^6.41.0", - "@huggingface/hub": "^2.11.0", - "@larksuiteoapi/node-sdk": "^1.60.0", + "@codemirror/view": "^6.42.1", + "@huggingface/hub": "^2.11.2", + "@larksuiteoapi/node-sdk": "^1.63.1", "@uiw/react-codemirror": "^4.25.9", "@xyflow/react": "^12.10.2", "ai": "^6.0.99", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.9.0", "cheerio": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -55,19 +58,19 @@ "dagre": "^0.8.5", "drizzle-orm": "^0.45.2", "highlight.js": "^11.11.1", - "katex": "^0.16.44", - "lucide-react": "^1.7.0", + "katex": "^0.16.45", + "lucide-react": "^1.14.0", "minimatch": "^10.2.5", - "nanoid": "^5.1.7", - "next": "16.2.2", - "next-intl": "^4.9.0", + "nanoid": "^5.1.11", + "next": "16.2.6", + "next-intl": "^4.11.1", "next-themes": "^0.4.6", "pdf-parse": "^2.4.5", "radix-ui": "^1.4.3", - "react": "19.2.4", - "react-dom": "19.2.4", + "react": "19.2.6", + "react-dom": "19.2.6", "react-markdown": "^10.1.0", - "react-resizable-panels": "^4.8.0", + "react-resizable-panels": "^4.11.0", "rehype-highlight": "^7.0.2", "rehype-katex": "^7.0.1", "rehype-sanitize": "^6.0.0", @@ -78,10 +81,10 @@ "remark-rehype": "^11.1.2", "sonner": "^2.0.7", "swr": "^2.4.1", - "tailwind-merge": "^3.5.0", - "three": "^0.183.2", + "tailwind-merge": "^3.6.0", + "three": "^0.184.0", "unified": "^11.0.5", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "optionalDependencies": { "canvas": "^3.2.3" @@ -94,14 +97,14 @@ "@types/pdf-parse": "^1.1.5", "@types/react": "^19", "@types/react-dom": "^19", - "@types/three": "^0.183.1", + "@types/three": "^0.184.1", "drizzle-kit": "^0.31.10", "eslint": "^9", - "eslint-config-next": "16.2.2", - "shadcn": "^4.1.2", + "eslint-config-next": "16.2.6", + "shadcn": "^4.7.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "6.0.2", - "vitest": "^4.1.2" + "vitest": "^4.1.5" } } diff --git a/plugins/innoclaw-cli/.codex-plugin/plugin.json b/plugins/innoclaw-cli/.codex-plugin/plugin.json new file mode 100644 index 00000000..5ad7240e --- /dev/null +++ b/plugins/innoclaw-cli/.codex-plugin/plugin.json @@ -0,0 +1,46 @@ +{ + "name": "innoclaw-cli", + "version": "0.1.0", + "description": "Command-line plugin for operating InnoClaw and Deep Research workflows from Codex.", + "author": { + "name": "InnoClaw Contributors", + "email": "support@openai.com", + "url": "https://github.com/SpectrAI-Initiative/InnoClaw" + }, + "homepage": "https://github.com/SpectrAI-Initiative/InnoClaw", + "repository": "https://github.com/SpectrAI-Initiative/InnoClaw", + "license": "Apache-2.0", + "keywords": [ + "innoclaw", + "cli", + "deep-research", + "nextjs", + "research-assistant", + "codex-plugin" + ], + "skills": "./skills/", + "interface": { + "displayName": "InnoClaw CLI", + "shortDescription": "Run InnoClaw app and Deep Research workflows from the terminal", + "longDescription": "Use InnoClaw CLI to start the local app, run build and test commands, manage workspaces, create Deep Research sessions, start runs, and export final reports without leaving the terminal.", + "developerName": "InnoClaw Contributors", + "category": "Research", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://github.com/SpectrAI-Initiative/InnoClaw", + "privacyPolicyURL": "https://openai.com/policies/privacy-policy/", + "termsOfServiceURL": "https://openai.com/policies/terms-of-use/", + "defaultPrompt": [ + "Start the local InnoClaw app and verify it is ready", + "Create and run a Deep Research session from the command line", + "Export the latest Deep Research report to the workspace" + ], + "brandColor": "#0F766E", + "composerIcon": "./assets/innoclaw-cli-small.svg", + "logo": "./assets/innoclaw-cli-logo.svg", + "screenshots": [] + } +} diff --git a/plugins/innoclaw-cli/README.md b/plugins/innoclaw-cli/README.md new file mode 100644 index 00000000..145e52be --- /dev/null +++ b/plugins/innoclaw-cli/README.md @@ -0,0 +1,39 @@ +# InnoClaw CLI Plugin + +`innoclaw-cli` adapts this repository into a repo-local Codex plugin plus a local terminal command. + +## What it provides + +- `innoclaw app dev|build|lint|test|start` +- `innoclaw doctor` +- `innoclaw workspace list|add` +- `innoclaw research list|create|show|run|export` + +The Deep Research commands call the existing HTTP API exposed by the local Next.js app. By default the CLI targets `http://localhost:3000`, or `INNOCLAW_BASE_URL` if set. + +## Local usage + +From the repository root: + +```bash +node plugins/innoclaw-cli/scripts/innoclaw-cli.mjs --help +``` + +To install the local command via npm shim: + +```bash +npm link +innoclaw --help +``` + +## Examples + +```bash +innoclaw doctor +innoclaw app dev +innoclaw workspace list +innoclaw workspace add --name notebooklm --path "$PWD" +innoclaw research create --workspace-id --title "Survey of time-series Transformer architectures" --content "Write a deep research report." +innoclaw research run --session-id +innoclaw research export --session-id +``` diff --git a/plugins/innoclaw-cli/agents/openai.yaml b/plugins/innoclaw-cli/agents/openai.yaml new file mode 100644 index 00000000..e9138efa --- /dev/null +++ b/plugins/innoclaw-cli/agents/openai.yaml @@ -0,0 +1,9 @@ +interface: + display_name: "InnoClaw CLI" + short_description: "Run InnoClaw app workflows and Deep Research sessions from the terminal" + icon_small: "./assets/innoclaw-cli-small.svg" + icon_large: "./assets/innoclaw-cli-logo.svg" + default_prompt: "Use InnoClaw CLI to run the local app, create a Deep Research session, and export the resulting report." + +dependencies: + tools: [] diff --git a/plugins/innoclaw-cli/assets/innoclaw-cli-logo.svg b/plugins/innoclaw-cli/assets/innoclaw-cli-logo.svg new file mode 100644 index 00000000..5b30839b --- /dev/null +++ b/plugins/innoclaw-cli/assets/innoclaw-cli-logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/innoclaw-cli/assets/innoclaw-cli-small.svg b/plugins/innoclaw-cli/assets/innoclaw-cli-small.svg new file mode 100644 index 00000000..f47c03e9 --- /dev/null +++ b/plugins/innoclaw-cli/assets/innoclaw-cli-small.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs b/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs new file mode 100755 index 00000000..f565efb2 --- /dev/null +++ b/plugins/innoclaw-cli/scripts/innoclaw-cli.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; + +const DEFAULT_BASE_URL = process.env.INNOCLAW_BASE_URL || "http://localhost:3000"; +const REPO_ROOT = process.cwd(); + +function printHelp() { + console.log(`InnoClaw CLI + +Usage: + innoclaw doctor + innoclaw app [-- ] + innoclaw workspace list [--base-url ] + innoclaw workspace add --name --path [--git] [--git-remote-url ] [--base-url ] + innoclaw research list --workspace-id [--base-url ] + innoclaw research create --workspace-id --title [--content <text>] [--interface-only] [--base-url <url>] + innoclaw research show --session-id <id> [--base-url <url>] + innoclaw research run --session-id <id> [--base-url <url>] + innoclaw research export --session-id <id> [--filename <name>] [--base-url <url>] +`); +} + +function parseArgs(argv) { + const positional = []; + const flags = {}; + const passthrough = []; + let collectingPassthrough = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (collectingPassthrough) { + passthrough.push(arg); + continue; + } + if (arg === "--") { + collectingPassthrough = true; + continue; + } + if (!arg.startsWith("--")) { + positional.push(arg); + continue; + } + + const key = arg.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + flags[key] = true; + continue; + } + + flags[key] = next; + i += 1; + } + + return { positional, flags, passthrough }; +} + +function requireFlag(flags, name) { + const value = flags[name]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Missing required --${name}`); + } + return value.trim(); +} + +function getBaseUrl(flags) { + return typeof flags["base-url"] === "string" && flags["base-url"].trim().length > 0 + ? flags["base-url"].trim().replace(/\/$/, "") + : DEFAULT_BASE_URL.replace(/\/$/, ""); +} + +async function requestJson(path, { method = "GET", body, baseUrl }) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: body ? { "Content-Type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = payload && typeof payload.error === "string" ? payload.error : `${response.status} ${response.statusText}`; + throw new Error(message); + } + + return payload; +} + +function runNpmScript(script, extraArgs) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn("npm", ["run", script, ...(extraArgs.length > 0 ? ["--", ...extraArgs] : [])], { + cwd: REPO_ROOT, + stdio: "inherit", + shell: false, + }); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + return; + } + rejectPromise(new Error(`npm run ${script} exited with code ${code}`)); + }); + child.on("error", rejectPromise); + }); +} + +function formatJson(value) { + console.log(JSON.stringify(value, null, 2)); +} + +async function handleDoctor() { + const checks = { + node: process.version, + repoRoot: REPO_ROOT, + hasEnvLocal: existsSync(resolve(REPO_ROOT, ".env.local")), + hasDataDir: existsSync(resolve(REPO_ROOT, "data")), + hasNodeModules: existsSync(resolve(REPO_ROOT, "node_modules")), + }; + formatJson(checks); +} + +async function handleWorkspace(command, flags) { + const baseUrl = getBaseUrl(flags); + if (command === "list") { + const data = await requestJson("/api/workspaces", { baseUrl }); + formatJson(data); + return; + } + + if (command === "add") { + const folderPath = resolve(requireFlag(flags, "path")); + if (!existsSync(folderPath) || !statSync(folderPath).isDirectory()) { + throw new Error(`Workspace path does not exist or is not a directory: ${folderPath}`); + } + const payload = { + name: requireFlag(flags, "name"), + folderPath, + isGitRepo: flags.git === true, + gitRemoteUrl: typeof flags["git-remote-url"] === "string" ? flags["git-remote-url"] : undefined, + }; + const data = await requestJson("/api/workspaces", { + method: "POST", + body: payload, + baseUrl, + }); + formatJson(data); + return; + } + + throw new Error(`Unsupported workspace command: ${command}`); +} + +async function handleResearch(command, flags) { + const baseUrl = getBaseUrl(flags); + + if (command === "list") { + const workspaceId = requireFlag(flags, "workspace-id"); + const data = await requestJson(`/api/deep-research/sessions?workspaceId=${encodeURIComponent(workspaceId)}`, { + baseUrl, + }); + formatJson(data); + return; + } + + if (command === "create") { + const payload = { + workspaceId: requireFlag(flags, "workspace-id"), + title: requireFlag(flags, "title"), + content: typeof flags.content === "string" ? flags.content : undefined, + config: flags["interface-only"] === true ? { interfaceOnly: true } : undefined, + }; + const data = await requestJson("/api/deep-research/sessions", { + method: "POST", + body: payload, + baseUrl, + }); + formatJson(data); + return; + } + + if (command === "show") { + const sessionId = requireFlag(flags, "session-id"); + const data = await requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}`, { + baseUrl, + }); + formatJson(data); + return; + } + + if (command === "run") { + const sessionId = requireFlag(flags, "session-id"); + const data = await requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}/run`, { + method: "POST", + body: {}, + baseUrl, + }); + formatJson(data); + return; + } + + if (command === "export") { + const sessionId = requireFlag(flags, "session-id"); + const payload = typeof flags.filename === "string" ? { filename: flags.filename } : {}; + const data = await requestJson(`/api/deep-research/sessions/${encodeURIComponent(sessionId)}/export`, { + method: "POST", + body: payload, + baseUrl, + }); + formatJson(data); + return; + } + + throw new Error(`Unsupported research command: ${command}`); +} + +async function main() { + const { positional, flags, passthrough } = parseArgs(process.argv.slice(2)); + if (positional.length === 0 || flags.help === true) { + printHelp(); + return; + } + + const [group, command] = positional; + + if (group === "doctor") { + await handleDoctor(); + return; + } + + if (group === "app") { + if (!command || !["dev", "build", "lint", "test", "start"].includes(command)) { + throw new Error("Usage: innoclaw app <dev|build|lint|test|start> [-- <extra npm args...>]"); + } + await runNpmScript(command, passthrough); + return; + } + + if (group === "workspace") { + if (!command) { + throw new Error("Usage: innoclaw workspace <list|add> ..."); + } + await handleWorkspace(command, flags); + return; + } + + if (group === "research") { + if (!command) { + throw new Error("Usage: innoclaw research <list|create|show|run|export> ..."); + } + await handleResearch(command, flags); + return; + } + + throw new Error(`Unknown command group: ${group}`); +} + +main().catch((error) => { + console.error(`[innoclaw] ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +}); diff --git a/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md b/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md new file mode 100644 index 00000000..72f16d15 --- /dev/null +++ b/plugins/innoclaw-cli/skills/innoclaw-cli/SKILL.md @@ -0,0 +1,49 @@ +--- +name: innoclaw-cli +description: Use the local InnoClaw CLI to run app workflows and Deep Research sessions from the terminal. Trigger when the user wants command-line control over this repository instead of only using the web UI. +--- + +# InnoClaw CLI + +Use the `innoclaw` command from the repository root for local operation. + +## Command groups + +### App lifecycle + +```bash +innoclaw doctor +innoclaw app dev +innoclaw app build +innoclaw app lint +innoclaw app test +innoclaw app start +``` + +### Workspace management + +```bash +innoclaw workspace list +innoclaw workspace add --name notebooklm --path "$PWD" +``` + +### Deep Research + +```bash +innoclaw research list --workspace-id <workspace-id> +innoclaw research create --workspace-id <workspace-id> --title "Survey of time-series Transformer architectures" --content "Write a deep research report." +innoclaw research show --session-id <session-id> +innoclaw research run --session-id <session-id> +innoclaw research export --session-id <session-id> +``` + +## Base URL + +- Defaults to `http://localhost:3000` +- Override with `--base-url` or `INNOCLAW_BASE_URL` + +## Usage notes + +- `research create`, `research run`, and `research export` require the local app server to be running. +- `workspace add` expects a filesystem path that already exists on disk. +- The CLI is intentionally thin: it wraps existing repo commands and HTTP APIs rather than bypassing them. diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index dd1f75c7..3e97553a 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -3,29 +3,29 @@ set -e echo "==> InnoClaw startup checks" -# ── 1. Writable data directory ─────────────────────────────────── +# 1. Writable data directory if [ ! -w "/app/data" ]; then echo "ERROR: /app/data is not writable. Check your volume mount permissions." exit 1 fi echo " [ok] /app/data is writable" -# ── 2. Workspace roots ─────────────────────────────────────────── +# 2. Workspace roots if [ -z "$WORKSPACE_ROOTS" ]; then - echo " [warn] WORKSPACE_ROOTS is not set — users won't be able to open workspaces" + echo " [warn] WORKSPACE_ROOTS is not set - users won't be able to open workspaces" else IFS=',' ; for root in $WORKSPACE_ROOTS; do root=$(echo "$root" | xargs) # trim whitespace if [ -d "$root" ]; then echo " [ok] workspace root exists: $root" else - echo " [warn] workspace root missing: $root — mount it as a volume" + echo " [warn] workspace root missing: $root - mount it as a volume" fi done unset IFS fi -# ── 3. API key check ───────────────────────────────────────────── +# 3. API key check has_key=false for var in OPENAI_API_KEY ANTHROPIC_API_KEY GEMINI_API_KEY MOONSHOT_API_KEY DEEPSEEK_API_KEY QWEN_API_KEY SHLAB_API_KEY MINIMAX_API_KEY ZHIPU_API_KEY; do eval val=\$$var @@ -37,15 +37,14 @@ done if [ "$has_key" = true ]; then echo " [ok] at least one AI API key is configured" else - echo " [warn] no AI API key detected — chat features will not work" + echo " [warn] no AI API key detected - chat features will not work" fi -# ── 4. Run database migrations ─────────────────────────────────── -echo "==> Running database migrations" -npx drizzle-kit migrate 2>&1 || { - echo " [warn] drizzle-kit migrate failed — the app may still start with an existing DB" -} +# 4. Database migrations +# The app runs Drizzle migrations during startup via src/lib/db/migrate.ts. +# Keep the entrypoint lightweight and avoid depending on a local CLI shim. +echo "==> Database migrations will run during app startup" -# ── 5. Start the server ────────────────────────────────────────── +# 5. Start the server echo "==> Starting InnoClaw on port ${PORT:-3000}" exec node server.js diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx new file mode 100644 index 00000000..bc6f1b6b --- /dev/null +++ b/src/app/admin/users/page.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import useSWR from "swr"; +import { Header } from "@/components/layout/header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { fetcher } from "@/lib/fetcher"; +import type { PublicUser } from "@/types/auth"; + +export default function UserManagementPage() { + const { data, mutate, isLoading } = useSWR<{ users: PublicUser[] }>( + "/api/admin/users", + fetcher, + ); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [role, setRole] = useState<"admin" | "user">("user"); + const [error, setError] = useState(""); + + async function request(path: string, init: RequestInit) { + const res = await fetch(path, { + ...init, + credentials: "include", + headers: { + "Content-Type": "application/json", + ...(init.headers || {}), + }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.error || "Request failed"); + } + return data; + } + + async function createUser(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + setError(""); + try { + await request("/api/admin/users", { + method: "POST", + body: JSON.stringify({ email, name, password, role }), + }); + setEmail(""); + setName(""); + setPassword(""); + setRole("user"); + mutate(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create user"); + } + } + + async function updateUser(userId: string, updates: Record<string, unknown>) { + setError(""); + try { + await request("/api/admin/users", { + method: "PATCH", + body: JSON.stringify({ userId, ...updates }), + }); + mutate(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update user"); + } + } + + async function resetPassword(userId: string) { + const nextPassword = window.prompt("New password, at least 8 characters"); + if (!nextPassword) return; + await updateUser(userId, { password: nextPassword }); + } + + async function deleteUser(userId: string) { + if (!window.confirm("Delete this user and transfer their data to your admin account?")) { + return; + } + setError(""); + try { + await request("/api/admin/users", { + method: "DELETE", + body: JSON.stringify({ userId }), + }); + mutate(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete user"); + } + } + + const users = data?.users ?? []; + + return ( + <div className="min-h-screen bg-background"> + <Header /> + <main className="mx-auto max-w-6xl space-y-6 px-4 py-8"> + <div> + <h1 className="text-2xl font-semibold">User management</h1> + <p className="mt-1 text-sm text-muted-foreground"> + Manage local accounts, roles, status, and passwords. + </p> + </div> + + <Card> + <CardHeader> + <CardTitle>Create user</CardTitle> + <CardDescription>Admins can create accounts in addition to open registration.</CardDescription> + </CardHeader> + <CardContent> + <form className="grid gap-4 md:grid-cols-[1fr_1fr_1fr_140px_auto]" onSubmit={createUser}> + <div className="space-y-2"> + <Label htmlFor="new-email">Email</Label> + <Input id="new-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required /> + </div> + <div className="space-y-2"> + <Label htmlFor="new-name">Name</Label> + <Input id="new-name" value={name} onChange={(e) => setName(e.target.value)} /> + </div> + <div className="space-y-2"> + <Label htmlFor="new-password">Password</Label> + <Input id="new-password" type="password" minLength={8} value={password} onChange={(e) => setPassword(e.target.value)} required /> + </div> + <div className="space-y-2"> + <Label>Role</Label> + <Select value={role} onValueChange={(value) => setRole(value as "admin" | "user")}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="user">User</SelectItem> + <SelectItem value="admin">Admin</SelectItem> + </SelectContent> + </Select> + </div> + <div className="flex items-end"> + <Button type="submit">Create</Button> + </div> + </form> + </CardContent> + </Card> + + {error && <p className="text-sm text-destructive">{error}</p>} + + <Card> + <CardHeader> + <CardTitle>Users</CardTitle> + <CardDescription>{isLoading ? "Loading users..." : `${users.length} account(s)`}</CardDescription> + </CardHeader> + <CardContent> + <div className="overflow-x-auto"> + <table className="w-full text-sm"> + <thead className="border-b text-left text-muted-foreground"> + <tr> + <th className="py-2 pr-4 font-medium">User</th> + <th className="py-2 pr-4 font-medium">Role</th> + <th className="py-2 pr-4 font-medium">Active</th> + <th className="py-2 pr-4 font-medium">Last login</th> + <th className="py-2 text-right font-medium">Actions</th> + </tr> + </thead> + <tbody> + {users.map((user) => ( + <tr key={user.id} className="border-b last:border-0"> + <td className="py-3 pr-4"> + <div className="font-medium">{user.name}</div> + <div className="text-muted-foreground">{user.email}</div> + </td> + <td className="py-3 pr-4"> + <Select value={user.role} onValueChange={(value) => updateUser(user.id, { role: value })}> + <SelectTrigger className="w-28"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + <SelectItem value="user">User</SelectItem> + <SelectItem value="admin">Admin</SelectItem> + </SelectContent> + </Select> + </td> + <td className="py-3 pr-4"> + <div className="flex items-center gap-2"> + <Switch checked={user.isActive} onCheckedChange={(checked) => updateUser(user.id, { isActive: checked })} /> + <Badge variant={user.isActive ? "secondary" : "outline"}> + {user.isActive ? "Active" : "Disabled"} + </Badge> + </div> + </td> + <td className="py-3 pr-4 text-muted-foreground"> + {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : "Never"} + </td> + <td className="py-3 text-right"> + <div className="flex justify-end gap-2"> + <Button variant="outline" size="sm" onClick={() => resetPassword(user.id)}> + Reset password + </Button> + <Button variant="destructive" size="sm" onClick={() => deleteUser(user.id)}> + Delete + </Button> + </div> + </td> + </tr> + ))} + </tbody> + </table> + </div> + </CardContent> + </Card> + </main> + </div> + ); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..bd6fdc7a --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,189 @@ +import { NextRequest, NextResponse } from "next/server"; +import { and, count, eq } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { db } from "@/lib/db"; +import { + hfDatasets, + scheduledTasks, + skills, + userSessions, + users, + workspaces, +} from "@/lib/db/schema"; +import { requireAdmin } from "@/lib/auth/server"; +import { hashPassword } from "@/lib/auth/password"; +import { jsonError } from "@/lib/api-errors"; + +function publicUserRow(user: typeof users.$inferSelect) { + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + isActive: user.isActive, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +async function activeAdminCount(): Promise<number> { + const [row] = await db + .select({ count: count() }) + .from(users) + .where(and(eq(users.role, "admin"), eq(users.isActive, true))); + return row.count; +} + +async function assertNotLastActiveAdmin(targetUserId: string): Promise<NextResponse | null> { + const [target] = await db + .select() + .from(users) + .where(eq(users.id, targetUserId)) + .limit(1); + + if (!target) { + return jsonError("User not found", 404); + } + + if (target.role === "admin" && target.isActive && (await activeAdminCount()) <= 1) { + return jsonError("Cannot remove the last active administrator", 400); + } + + return null; +} + +export async function GET(request: NextRequest) { + const auth = await requireAdmin(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rows = await db.select().from(users); + return NextResponse.json({ users: rows.map(publicUserRow) }); +} + +export async function POST(request: NextRequest) { + const auth = await requireAdmin(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const body = await request.json(); + const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + const name = typeof body.name === "string" ? body.name.trim() : ""; + const password = typeof body.password === "string" ? body.password : ""; + const role = body.role === "admin" ? "admin" : "user"; + + if (!email || !password) { + return jsonError("Missing email or password", 400); + } + if (password.length < 8) { + return jsonError("Password must be at least 8 characters", 400); + } + + const now = new Date().toISOString(); + await db.insert(users).values({ + id: nanoid(), + email, + name: name || email.split("@")[0] || "User", + passwordHash: hashPassword(password), + role, + isActive: true, + createdAt: now, + updatedAt: now, + }); + + return NextResponse.json({ success: true }, { status: 201 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create user"; + const status = message.includes("UNIQUE") ? 409 : 500; + return jsonError(status === 409 ? "Email is already registered" : message, status); + } +} + +export async function PATCH(request: NextRequest) { + const auth = await requireAdmin(request); + if (auth instanceof NextResponse) { + return auth; + } + + const body = await request.json(); + const userId = typeof body.userId === "string" ? body.userId : ""; + if (!userId) { + return jsonError("Missing userId", 400); + } + + if ((body.role && body.role !== "admin") || body.isActive === false) { + const lastAdminError = await assertNotLastActiveAdmin(userId); + if (lastAdminError) { + return lastAdminError; + } + } + + const updates: Partial<typeof users.$inferInsert> = { + updatedAt: new Date().toISOString(), + }; + + if (typeof body.name === "string") { + updates.name = body.name.trim() || "User"; + } + if (body.role === "admin" || body.role === "user") { + updates.role = body.role; + } + if (typeof body.isActive === "boolean") { + updates.isActive = body.isActive; + } + if (typeof body.password === "string" && body.password.length > 0) { + if (body.password.length < 8) { + return jsonError("Password must be at least 8 characters", 400); + } + updates.passwordHash = hashPassword(body.password); + } + + await db.update(users).set(updates).where(eq(users.id, userId)); + + if (body.isActive === false || updates.passwordHash) { + await db + .update(userSessions) + .set({ revokedAt: new Date().toISOString() }) + .where(eq(userSessions.userId, userId)); + } + + return NextResponse.json({ success: true }); +} + +export async function DELETE(request: NextRequest) { + const auth = await requireAdmin(request); + if (auth instanceof NextResponse) { + return auth; + } + + const body = await request.json(); + const userId = typeof body.userId === "string" ? body.userId : ""; + if (!userId) { + return jsonError("Missing userId", 400); + } + if (userId === auth.user.id) { + return jsonError("Administrators cannot delete their own account", 400); + } + + const lastAdminError = await assertNotLastActiveAdmin(userId); + if (lastAdminError) { + return lastAdminError; + } + + const transferToUserId = + typeof body.transferToUserId === "string" && body.transferToUserId + ? body.transferToUserId + : auth.user.id; + + await db.update(workspaces).set({ ownerUserId: transferToUserId }).where(eq(workspaces.ownerUserId, userId)); + await db.update(hfDatasets).set({ ownerUserId: transferToUserId }).where(eq(hfDatasets.ownerUserId, userId)); + await db.update(scheduledTasks).set({ ownerUserId: transferToUserId }).where(eq(scheduledTasks.ownerUserId, userId)); + await db.update(skills).set({ ownerUserId: transferToUserId }).where(eq(skills.ownerUserId, userId)); + await db.delete(users).where(eq(users.id, userId)); + + return NextResponse.json({ success: true, transferredToUserId: transferToUserId }); +} diff --git a/src/app/api/agent/buddy/route.ts b/src/app/api/agent/buddy/route.ts new file mode 100644 index 00000000..5da70e3f --- /dev/null +++ b/src/app/api/agent/buddy/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateText } from "ai"; +import { getConfiguredModelWithProvider, getModelFromOverride } from "@/lib/ai/provider"; +import { isAIAvailable } from "@/lib/ai/provider"; + +export async function POST(req: NextRequest) { + try { + if (!isAIAvailable()) { + return NextResponse.json({ error: "AI not configured" }, { status: 503 }); + } + + const body = await req.json(); + const { action } = body; + + // Use a fast/cheap model for buddy interactions + let model; + try { + const override = getModelFromOverride("anthropic", "claude-3-5-haiku-20241022"); + model = override.model; + } catch { + const configured = await getConfiguredModelWithProvider(); + model = configured.model; + } + + if (action === "hatch") { + const { species, rarity, stats } = body; + + const statsDesc = Object.entries(stats as Record<string, number>) + .map(([name, val]) => { + const level = val < 20 ? "very low" : val < 40 ? "low" : val < 60 ? "moderate" : val < 80 ? "high" : "very high"; + return `${name}=${val}/100 (${level})`; + }) + .join(", "); + + const result = await generateText({ + model, + prompt: `You are naming a small ${species} companion (${rarity} rarity) for a coding assistant. +Their stats: ${statsDesc} + +Generate: +1. A creative two-word name (like "Glitch Honker" or "Binary Puff") that fits their species and personality +2. A brief personality description (1-2 sentences) based on their stats + +Reply in exactly this format: +NAME: <name> +PERSONALITY: <personality>`, + maxOutputTokens: 100, + }); + + const text = result.text; + const nameMatch = text.match(/NAME:\s*(.+)/i); + const personalityMatch = text.match(/PERSONALITY:\s*(.+)/i); + + return NextResponse.json({ + name: nameMatch?.[1]?.trim() ?? `${rarity} ${species}`, + personality: personalityMatch?.[1]?.trim() ?? `A ${rarity} ${species} companion.`, + }); + } + + if (action === "react") { + const { lastMsg, companion } = body; + const preview = (lastMsg as string).slice(0, 500); + + const statsDesc = Object.entries(companion.stats as Record<string, number>) + .map(([name, val]) => { + const level = val < 20 ? "very low" : val < 40 ? "low" : val < 60 ? "moderate" : val < 80 ? "high" : "very high"; + return `${name}=${val}/100 (${level})`; + }) + .join(", "); + + const result = await generateText({ + model, + prompt: `You are ${companion.name}, a small ${companion.species} (${companion.rarity} rarity) who sits beside a coding terminal. +Your personality: ${companion.personality} +Your stats: ${statsDesc} + +How stats affect your behavior: +- DEBUGGING: High = give technical insights, Low = clueless about code +- PATIENCE: High = calm and supportive, Low = easily frustrated +- CHAOS: High = random and unpredictable, Low = orderly and steady +- WISDOM: High = thoughtful and deep, Low = naive and simple +- SNARK: High = sarcastic and witty, Low = earnest and sweet + +IMPORTANT: Reply in the same language as the assistant's message below. + +The AI assistant just said: +"${preview}" + +React with a single short witty comment (under 60 chars). Stay in character. No quotes, no emojis, no explanation.`, + maxOutputTokens: 60, + }); + + return NextResponse.json({ reaction: result.text.trim() }); + } + + return NextResponse.json({ error: `Unknown action: ${action}` }, { status: 400 }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Buddy operation failed"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/agent/memory/route.ts b/src/app/api/agent/memory/route.ts new file mode 100644 index 00000000..37c22806 --- /dev/null +++ b/src/app/api/agent/memory/route.ts @@ -0,0 +1,168 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { notes } from "@/lib/db/schema"; +import { eq, and, desc, like } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { streamText } from "ai"; +import { getConfiguredModelWithProvider } from "@/lib/ai/provider"; +import { formatDailyLogEntry, todayKey, buildDreamPrompt } from "@/lib/agent/kairos-memory"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException, requiredSearchParam } from "@/lib/api-errors"; + +/** + * GET /api/agent/memory?workspaceId=xxx + * Returns the memory index (all memory-type notes) for a workspace. + */ +export async function GET(req: NextRequest) { + try { + const workspaceId = requiredSearchParam(req, "workspaceId"); + if (workspaceId instanceof NextResponse) { + return workspaceId; + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } + + const memoryNotes = await db + .select() + .from(notes) + .where(and(eq(notes.workspaceId, workspaceId), eq(notes.type, "memory"))) + .orderBy(desc(notes.createdAt)); + + // Build a memory index summary + const index = memoryNotes + .map((n) => `- **${n.title}** (${n.createdAt}): ${n.content.slice(0, 100)}...`) + .join("\n"); + + return NextResponse.json({ + notes: memoryNotes, + index: index || "No memories yet.", + count: memoryNotes.length, + }); + } catch (error) { + return jsonException(error, "Failed to load memory"); + } +} + +/** + * POST /api/agent/memory + * Actions: "remember" (append to daily log), "dream" (consolidate memories) + */ +export async function POST(req: NextRequest) { + try { + const { workspaceId, action, text } = await req.json(); + + if (!workspaceId || !action) { + return jsonError("Missing workspaceId or action", 400); + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } + + if (action === "remember") { + if (!text) { + return jsonError("Missing text for remember", 400); + } + + const today = todayKey(); + const entry = formatDailyLogEntry(text); + + // Check if today's daily log already exists + const existing = await db + .select() + .from(notes) + .where( + and( + eq(notes.workspaceId, workspaceId), + eq(notes.type, "memory"), + like(notes.title, `Daily Log ${today}%`) + ) + ) + .limit(1); + + if (existing.length > 0) { + // Append to existing daily log + await db + .update(notes) + .set({ + content: existing[0].content + "\n" + entry, + updatedAt: new Date().toISOString(), + }) + .where(eq(notes.id, existing[0].id)); + + return NextResponse.json({ success: true, noteId: existing[0].id, appended: true }); + } else { + // Create new daily log + const noteId = nanoid(); + await db.insert(notes).values({ + id: noteId, + workspaceId, + title: `Daily Log ${today}`, + content: entry, + type: "memory", + }); + + return NextResponse.json({ success: true, noteId, appended: false }); + } + } + + if (action === "dream") { + // Load all memory notes for consolidation + const memoryNotes = await db + .select() + .from(notes) + .where(and(eq(notes.workspaceId, workspaceId), eq(notes.type, "memory"))) + .orderBy(desc(notes.createdAt)); + + const dailyLogs = memoryNotes + .filter((n) => n.title.startsWith("Daily Log")) + .map((n) => `### ${n.title}\n${n.content}`); + + const existingMemories = memoryNotes + .filter((n) => !n.title.startsWith("Daily Log")) + .map((n) => `### ${n.title}\n${n.content}`); + + const dreamPrompt = buildDreamPrompt(workspaceId, dailyLogs, existingMemories); + + // Use LLM to consolidate + const { model } = await getConfiguredModelWithProvider(); + + const result = streamText({ + model, + messages: [{ role: "user", content: dreamPrompt }], + abortSignal: req.signal, + }); + + // Collect the full response + let consolidated = ""; + for await (const chunk of result.textStream) { + consolidated += chunk; + } + + // Save consolidated memory + const noteId = nanoid(); + const now = new Date().toISOString(); + await db.insert(notes).values({ + id: noteId, + workspaceId, + title: `Memory Consolidation ${now.slice(0, 10)}`, + content: consolidated, + type: "memory", + createdAt: now, + updatedAt: now, + }); + + return NextResponse.json({ + success: true, + noteId, + content: consolidated, + }); + } + + return jsonError(`Unknown action: ${action}`, 400); + } catch (error) { + return jsonException(error, "Memory operation failed"); + } +} diff --git a/src/app/api/agent/route.ts b/src/app/api/agent/route.ts index 0c6503ce..a0ae6f3b 100644 --- a/src/app/api/agent/route.ts +++ b/src/app/api/agent/route.ts @@ -1,7 +1,7 @@ import { NextRequest } from "next/server"; import { streamText, convertToModelMessages, UIMessage, stepCountIs } from "ai"; import { getConfiguredModelWithProvider, getModelFromOverride, isAIAvailable } from "@/lib/ai/provider"; -import { createAgentTools } from "@/lib/ai/agent-tools"; +import { createAgentTools } from "@/lib/ai/tools"; import { buildAgentSystemPrompt, buildAgentLongSystemPrompt, buildPlanSystemPrompt, buildAskSystemPrompt } from "@/lib/ai/prompts"; import { buildSkillSystemPrompt } from "@/lib/ai/skill-prompt"; import { runtimeProviderSupportsTools } from "@/lib/ai/runtime-capabilities"; @@ -10,6 +10,7 @@ import { skills } from "@/lib/db/schema"; import { and, eq, or, isNull } from "drizzle-orm"; import { parseSkillRow } from "@/lib/db/skills-utils"; import { ensureProjectDefaultSkills } from "@/lib/db/default-skills"; +import { requirePathAccess, requireSkillAccess, requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function POST(req: NextRequest) { try { @@ -21,6 +22,14 @@ export async function POST(req: NextRequest) { if (!workspaceId || !cwd || typeof cwd !== "string") { return new Response("Missing workspaceId or cwd", { status: 400 }); } + const workspaceAccess = await requireWorkspaceAccess(req, workspaceId); + if (workspaceAccess instanceof Response) { + return workspaceAccess; + } + const pathAccess = await requirePathAccess(req, cwd); + if (pathAccess instanceof Response) { + return pathAccess; + } // Validate request-level model override fields before use if (llmProvider !== undefined && llmModel !== undefined) { @@ -59,6 +68,11 @@ export async function POST(req: NextRequest) { let tools; if (skillId) { + const skillAccess = await requireSkillAccess(req, skillId); + if (skillAccess instanceof Response) { + return skillAccess; + } + // Skill mode: load skill from DB and use skill-specific prompt + tools const skillRows = await db .select() @@ -188,7 +202,15 @@ export async function POST(req: NextRequest) { }, }); - return result.toUIMessageStreamResponse(); + // Resolve the model ID string for cost tracking on the client side + const modelIdStr = typeof model === "string" ? model : model.modelId; + + return result.toUIMessageStreamResponse({ + headers: { + "X-Agent-Model": modelIdStr, + "X-Agent-Provider": providerId, + }, + }); } catch (error) { console.error("Agent error:", error); return new Response( diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..fbf44aea --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,61 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { + attachAuthCookies, + createAuthSession, + findUserByEmail, + normalizeUserEmail, +} from "@/lib/auth/server"; +import { verifyPassword } from "@/lib/auth/password"; +import { jsonError } from "@/lib/api-errors"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const email = typeof body.email === "string" ? body.email.trim() : ""; + const password = typeof body.password === "string" ? body.password : ""; + + if (!email || !password) { + return jsonError("Missing email or password", 400); + } + + const user = await findUserByEmail(normalizeUserEmail(email)); + if (!user || !user.isActive) { + return jsonError("Invalid credentials", 401); + } + + if (!verifyPassword(password, user.passwordHash)) { + return jsonError("Invalid credentials", 401); + } + + const session = await createAuthSession(user.id); + const now = new Date().toISOString(); + await db + .update(users) + .set({ lastLoginAt: now, updatedAt: now }) + .where(eq(users.id, user.id)); + + const response = NextResponse.json( + { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + isActive: user.isActive, + lastLoginAt: now, + createdAt: user.createdAt, + updatedAt: now, + }, + }, + { status: 200 }, + ); + attachAuthCookies(response, session); + return response; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to login"; + return jsonError(message, 500); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..ad4ff689 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from "next/server"; +import { clearAuthCookies, revokeCurrentSession } from "@/lib/auth/server"; + +export async function POST(request: NextRequest) { + await revokeCurrentSession(request); + const response = NextResponse.json({ success: true }); + clearAuthCookies(response); + return response; +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 00000000..86a290ba --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getAuthContext, refreshAuthSessionIfNeeded, unauthorizedResponse } from "@/lib/auth/server"; + +export async function GET(request: NextRequest) { + const auth = await getAuthContext(request); + if (!auth) { + return unauthorizedResponse(); + } + + const response = NextResponse.json({ + user: auth.user, + session: { expiresAt: auth.session.expiresAt }, + }); + return refreshAuthSessionIfNeeded(response, auth); +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 00000000..9cfd514c --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { + attachAuthCookies, + claimExistingDataForFirstUser, + createAuthSession, + createUser, + findUserByEmail, + getUserCount, + normalizeUserEmail, +} from "@/lib/auth/server"; +import { hashPassword } from "@/lib/auth/password"; +import { jsonError } from "@/lib/api-errors"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const email = typeof body.email === "string" ? body.email.trim() : ""; + const password = typeof body.password === "string" ? body.password : ""; + const name = typeof body.name === "string" ? body.name.trim() : ""; + + if (!email || !password) { + return jsonError("Missing email or password", 400); + } + + if (password.length < 8) { + return jsonError("Password must be at least 8 characters", 400); + } + + const existing = await findUserByEmail(email); + if (existing) { + return jsonError("Email is already registered", 409); + } + + const userCount = await getUserCount(); + const created = await createUser({ + email: normalizeUserEmail(email), + name: name || undefined, + passwordHash: hashPassword(password), + role: userCount === 0 ? "admin" : "user", + isActive: true, + }); + + if (userCount === 0) { + await claimExistingDataForFirstUser(created.id); + } + + const session = await createAuthSession(created.id); + await db + .update(users) + .set({ lastLoginAt: new Date().toISOString() }) + .where(eq(users.id, created.id)); + + const response = NextResponse.json( + { user: created, requiresSetup: userCount === 0 }, + { status: 201 }, + ); + attachAuthCookies(response, session); + return response; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to register"; + return jsonError(message, 500); + } +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 34146976..aadbeea4 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -6,6 +6,7 @@ import { buildChatSystemPrompt } from "@/lib/ai/prompts"; import { db } from "@/lib/db"; import { chatMessages } from "@/lib/db/schema"; import { nanoid } from "nanoid"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function POST(req: NextRequest) { try { @@ -14,6 +15,10 @@ export async function POST(req: NextRequest) { if (!workspaceId) { return new Response("Missing workspaceId", { status: 400 }); } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof Response) { + return access; + } if (!isAIAvailable()) { return new Response("AI is not configured. Please set OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY in .env.local.", { status: 503 }); diff --git a/src/app/api/cluster/operations/route.ts b/src/app/api/cluster/operations/route.ts index 8f5919ab..641cdbf2 100644 --- a/src/app/api/cluster/operations/route.ts +++ b/src/app/api/cluster/operations/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { listClusterOps } from "@/lib/cluster/operations"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; /** * GET /api/cluster/operations?workspaceId=xxx&limit=50&offset=0 @@ -17,6 +18,13 @@ export async function GET(request: NextRequest) { ); const offset = Math.max(Number(searchParams.get("offset")) || 0, 0); + if (workspaceId) { + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } + } + const ops = await listClusterOps({ workspaceId, limit, offset }); return NextResponse.json(ops); } catch (err) { diff --git a/src/app/api/daily-report/route.ts b/src/app/api/daily-report/route.ts index 681a7254..0f6ef042 100644 --- a/src/app/api/daily-report/route.ts +++ b/src/app/api/daily-report/route.ts @@ -6,6 +6,7 @@ import { import { db } from "@/lib/db"; import { notes } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function POST(req: NextRequest) { try { @@ -17,6 +18,10 @@ export async function POST(req: NextRequest) { { status: 400 } ); } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } const dateStr = date || getTodayDateString(); const result = await generateDailyReport( diff --git a/src/app/api/datasets/[datasetId]/cancel/route.ts b/src/app/api/datasets/[datasetId]/cancel/route.ts index d35f731c..39ef20b1 100644 --- a/src/app/api/datasets/[datasetId]/cancel/route.ts +++ b/src/app/api/datasets/[datasetId]/cancel/route.ts @@ -3,32 +3,25 @@ import { db } from "@/lib/db"; import { hfDatasets } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { cancelDownload, removeProgress } from "@/lib/hf-datasets/progress"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; /** * POST /api/datasets/[datasetId]/cancel - Cancel an in-progress or paused download */ -export async function POST(_request: NextRequest, { params }: RouteParams) { +export async function POST(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; - - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; } - const status = rows[0].status; + const status = access.dataset.status; if (status !== "downloading" && status !== "pending" && status !== "paused") { - return NextResponse.json( - { error: "Dataset is not in a cancellable state" }, - { status: 400 } - ); + return jsonError("Dataset is not in a cancellable state", 400); } // Try to abort if actively downloading @@ -46,7 +39,6 @@ export async function POST(_request: NextRequest, { params }: RouteParams) { return NextResponse.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to cancel download"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to cancel download"); } } diff --git a/src/app/api/datasets/[datasetId]/pause/route.ts b/src/app/api/datasets/[datasetId]/pause/route.ts index dec659eb..e4e1315f 100644 --- a/src/app/api/datasets/[datasetId]/pause/route.ts +++ b/src/app/api/datasets/[datasetId]/pause/route.ts @@ -3,32 +3,25 @@ import { db } from "@/lib/db"; import { hfDatasets } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { pauseDownload } from "@/lib/hf-datasets/progress"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; /** * POST /api/datasets/[datasetId]/pause - Pause an in-progress download */ -export async function POST(_request: NextRequest, { params }: RouteParams) { +export async function POST(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; - - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; } - const status = rows[0].status; + const status = access.dataset.status; if (status !== "downloading" && status !== "pending") { - return NextResponse.json( - { error: "Dataset is not currently downloading" }, - { status: 400 } - ); + return jsonError("Dataset is not currently downloading", 400); } const paused = pauseDownload(datasetId); @@ -44,7 +37,6 @@ export async function POST(_request: NextRequest, { params }: RouteParams) { return NextResponse.json({ success: true, paused }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to pause download"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to pause download"); } } diff --git a/src/app/api/datasets/[datasetId]/preview/route.ts b/src/app/api/datasets/[datasetId]/preview/route.ts index f18b8a2a..f8e34926 100644 --- a/src/app/api/datasets/[datasetId]/preview/route.ts +++ b/src/app/api/datasets/[datasetId]/preview/route.ts @@ -1,8 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { hfDatasets } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; import { previewItems } from "@/lib/hf-datasets/preview"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; @@ -12,41 +11,29 @@ type RouteParams = { params: Promise<{ datasetId: string }> }; export async function GET(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; + } + const { searchParams } = new URL(request.url); const split = searchParams.get("split") || "default"; const parsedN = parseInt(searchParams.get("n") || "20", 10); const n = Number.isNaN(parsedN) ? 20 : Math.max(1, Math.min(parsedN, 1000)); - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); - } - - const dataset = rows[0]; + const dataset = access.dataset; if (dataset.status !== "ready") { - return NextResponse.json( - { error: "Dataset is not ready for preview" }, - { status: 400 } - ); + return jsonError("Dataset is not ready for preview", 400); } if (!dataset.localPath) { - return NextResponse.json( - { error: "Dataset has no local path" }, - { status: 400 } - ); + return jsonError("Dataset has no local path", 400); } const result = await previewItems(dataset.localPath, split, n); return NextResponse.json({ split, ...result }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to preview dataset"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to preview dataset"); } } diff --git a/src/app/api/datasets/[datasetId]/refresh/route.ts b/src/app/api/datasets/[datasetId]/refresh/route.ts index 01387e43..c8485498 100644 --- a/src/app/api/datasets/[datasetId]/refresh/route.ts +++ b/src/app/api/datasets/[datasetId]/refresh/route.ts @@ -4,33 +4,26 @@ import { hfDatasets } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { buildManifest, computeStats } from "@/lib/hf-datasets/manifest"; import * as fs from "fs"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; /** * POST /api/datasets/[datasetId]/refresh - Recalculate manifest & stats from disk */ -export async function POST(_request: NextRequest, { params }: RouteParams) { +export async function POST(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; - - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; } - const dataset = rows[0]; + const dataset = access.dataset; if (!dataset.localPath || !fs.existsSync(dataset.localPath)) { - return NextResponse.json( - { error: "Dataset files not found on disk" }, - { status: 400 } - ); + return jsonError("Dataset files not found on disk", 400); } const manifest = buildManifest(dataset.localPath); @@ -59,7 +52,6 @@ export async function POST(_request: NextRequest, { params }: RouteParams) { numFiles: totalFiles, }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to refresh stats"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to refresh stats"); } } diff --git a/src/app/api/datasets/[datasetId]/retry/route.ts b/src/app/api/datasets/[datasetId]/retry/route.ts index df74aa50..d20ff995 100644 --- a/src/app/api/datasets/[datasetId]/retry/route.ts +++ b/src/app/api/datasets/[datasetId]/retry/route.ts @@ -6,32 +6,25 @@ import { downloadRepo } from "@/lib/hf-datasets/downloader"; import { buildManifest, computeStats } from "@/lib/hf-datasets/manifest"; import { setProgress, markFinished, removeProgress } from "@/lib/hf-datasets/progress"; import type { HfRepoType, HfDatasetSourceConfig } from "@/types"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; /** * POST /api/datasets/[datasetId]/retry - Retry a failed download */ -export async function POST(_request: NextRequest, { params }: RouteParams) { +export async function POST(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; - - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; } - const dataset = rows[0]; + const dataset = access.dataset; if (dataset.status === "downloading") { - return NextResponse.json( - { error: "Dataset is already downloading" }, - { status: 400 } - ); + return jsonError("Dataset is already downloading", 400); } // Reset status @@ -54,7 +47,7 @@ export async function POST(_request: NextRequest, { params }: RouteParams) { : null; if (!dataset.localPath) { - return NextResponse.json({ error: "Dataset has no local path" }, { status: 400 }); + return jsonError("Dataset has no local path", 400); } startRetryDownload(datasetId, { @@ -67,8 +60,7 @@ export async function POST(_request: NextRequest, { params }: RouteParams) { return NextResponse.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to retry download"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to retry download"); } } diff --git a/src/app/api/datasets/[datasetId]/route.ts b/src/app/api/datasets/[datasetId]/route.ts index 090e7cf1..563b9cb4 100644 --- a/src/app/api/datasets/[datasetId]/route.ts +++ b/src/app/api/datasets/[datasetId]/route.ts @@ -4,26 +4,22 @@ import { hfDatasets } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import * as fs from "fs"; import { removeProgress } from "@/lib/hf-datasets/progress"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; /** * GET /api/datasets/[datasetId] - Get dataset details */ -export async function GET(_request: NextRequest, { params }: RouteParams) { +export async function GET(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; } - - const row = rows[0]; + const row = access.dataset; return NextResponse.json({ ...row, sourceConfig: row.sourceConfig ? JSON.parse(row.sourceConfig) : null, @@ -31,8 +27,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { stats: row.stats ? JSON.parse(row.stats) : null, }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to get dataset"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to get dataset"); } } @@ -42,20 +37,15 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; + } + const { searchParams } = new URL(request.url); const deleteFiles = searchParams.get("deleteFiles") === "true"; - const rows = await db - .select() - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); - } - - const dataset = rows[0]; + const { dataset } = access; // Delete files if requested if (deleteFiles && dataset.localPath) { @@ -74,7 +64,6 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to delete dataset"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to delete dataset"); } } diff --git a/src/app/api/datasets/[datasetId]/status/route.ts b/src/app/api/datasets/[datasetId]/status/route.ts index f98e2ded..a62cc4b2 100644 --- a/src/app/api/datasets/[datasetId]/status/route.ts +++ b/src/app/api/datasets/[datasetId]/status/route.ts @@ -1,17 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { hfDatasets } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; import { getProgress } from "@/lib/hf-datasets/progress"; +import { requireDatasetAccess } from "@/lib/auth/ownership"; +import { jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; /** * GET /api/datasets/[datasetId]/status - Get live download progress */ -export async function GET(_request: NextRequest, { params }: RouteParams) { +export async function GET(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; + const access = await requireDatasetAccess(request, datasetId); + if (access instanceof NextResponse) { + return access; + } // Check in-memory progress first (for active downloads) const liveProgress = getProgress(datasetId); @@ -19,24 +22,8 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { return NextResponse.json(liveProgress); } - // Fall back to database status - const rows = await db - .select({ - status: hfDatasets.status, - progress: hfDatasets.progress, - lastError: hfDatasets.lastError, - sizeBytes: hfDatasets.sizeBytes, - numFiles: hfDatasets.numFiles, - }) - .from(hfDatasets) - .where(eq(hfDatasets.id, datasetId)) - .limit(1); - - if (rows.length === 0) { - return NextResponse.json({ error: "Dataset not found" }, { status: 404 }); - } - - const row = rows[0]; + // Fall back to database status from the access-checked row. + const row = access.dataset; return NextResponse.json({ datasetId, status: row.status, @@ -48,7 +35,6 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { totalFiles: row.numFiles ?? 0, }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to get status"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to get status"); } } diff --git a/src/app/api/datasets/[datasetId]/workspaces/route.ts b/src/app/api/datasets/[datasetId]/workspaces/route.ts index 9cbb91f2..e743fad0 100644 --- a/src/app/api/datasets/[datasetId]/workspaces/route.ts +++ b/src/app/api/datasets/[datasetId]/workspaces/route.ts @@ -3,6 +3,8 @@ import { db } from "@/lib/db"; import { datasetWorkspaceLinks, workspaces } from "@/lib/db/schema"; import { eq, and } from "drizzle-orm"; import { nanoid } from "nanoid"; +import { requireDatasetAccess, requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException, requiredSearchParam } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ datasetId: string }> }; @@ -12,6 +14,10 @@ type RouteParams = { params: Promise<{ datasetId: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; + const datasetAccess = await requireDatasetAccess(_request, datasetId); + if (datasetAccess instanceof NextResponse) { + return datasetAccess; + } const links = await db .select({ @@ -27,8 +33,7 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { return NextResponse.json(links); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to list linked workspaces"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list linked workspaces"); } } @@ -38,11 +43,19 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { export async function POST(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; + const datasetAccess = await requireDatasetAccess(request, datasetId); + if (datasetAccess instanceof NextResponse) { + return datasetAccess; + } const body = await request.json(); const { workspaceId } = body as { workspaceId: string }; if (!workspaceId) { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + return jsonError("Missing workspaceId", 400); + } + const workspaceAccess = await requireWorkspaceAccess(request, workspaceId); + if (workspaceAccess instanceof NextResponse) { + return workspaceAccess; } const existing = await db @@ -57,7 +70,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { .limit(1); if (existing.length > 0) { - return NextResponse.json({ error: "Already linked" }, { status: 409 }); + return jsonError("Already linked", 409); } const id = nanoid(); @@ -70,8 +83,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ id, datasetId, workspaceId }, { status: 201 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to link workspace"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to link workspace"); } } @@ -81,11 +93,17 @@ export async function POST(request: NextRequest, { params }: RouteParams) { export async function DELETE(request: NextRequest, { params }: RouteParams) { try { const { datasetId } = await params; - const { searchParams } = new URL(request.url); - const workspaceId = searchParams.get("workspaceId"); - - if (!workspaceId) { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + const datasetAccess = await requireDatasetAccess(request, datasetId); + if (datasetAccess instanceof NextResponse) { + return datasetAccess; + } + const workspaceId = requiredSearchParam(request, "workspaceId"); + if (workspaceId instanceof NextResponse) { + return workspaceId; + } + const workspaceAccess = await requireWorkspaceAccess(request, workspaceId); + if (workspaceAccess instanceof NextResponse) { + return workspaceAccess; } const existing = await db @@ -100,7 +118,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { .limit(1); if (existing.length === 0) { - return NextResponse.json({ error: "Link not found" }, { status: 404 }); + return jsonError("Link not found", 404); } await db @@ -109,7 +127,6 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ success: true }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to unlink workspace"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to unlink workspace"); } } diff --git a/src/app/api/datasets/import-local/route.ts b/src/app/api/datasets/import-local/route.ts index 9ab52455..b2b96c30 100644 --- a/src/app/api/datasets/import-local/route.ts +++ b/src/app/api/datasets/import-local/route.ts @@ -6,12 +6,19 @@ import { eq } from "drizzle-orm"; import * as path from "path"; import * as fs from "fs"; import { buildManifest, computeStats } from "@/lib/hf-datasets/manifest"; +import { requireAuth } from "@/lib/auth/server"; +import { requirePathAccess } from "@/lib/auth/ownership"; /** * POST /api/datasets/import-local - Import a local directory as a dataset */ export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); const { localPath, name } = body as { localPath: string; @@ -24,6 +31,11 @@ export async function POST(request: NextRequest) { // Resolve and validate path const resolvedPath = path.resolve(localPath); + const access = await requirePathAccess(request, resolvedPath); + if (access instanceof NextResponse) { + return access; + } + if (!fs.existsSync(resolvedPath)) { return NextResponse.json({ error: "Path does not exist" }, { status: 400 }); } @@ -49,6 +61,7 @@ export async function POST(request: NextRequest) { await db.insert(hfDatasets).values({ id, + ownerUserId: auth.user.id, name: displayName, repoId: resolvedPath, repoType: "dataset", diff --git a/src/app/api/datasets/modelscope-info/route.ts b/src/app/api/datasets/modelscope-info/route.ts index b82ab939..58edc0dc 100644 --- a/src/app/api/datasets/modelscope-info/route.ts +++ b/src/app/api/datasets/modelscope-info/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getModelScopeRepoInfo } from "@/lib/modelscope/metadata"; import type { HfRepoType } from "@/types"; +import { requireAuth } from "@/lib/auth/server"; const VALID_REPO_TYPES = new Set(["dataset", "model"]); @@ -9,6 +10,11 @@ const VALID_REPO_TYPES = new Set(["dataset", "model"]); */ export async function GET(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const { searchParams } = new URL(request.url); const repoId = searchParams.get("repoId"); const repoTypeParam = searchParams.get("repoType") || "dataset"; diff --git a/src/app/api/datasets/repo-info/route.ts b/src/app/api/datasets/repo-info/route.ts index 97ab2746..83a09077 100644 --- a/src/app/api/datasets/repo-info/route.ts +++ b/src/app/api/datasets/repo-info/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getRepoInfo } from "@/lib/hf-datasets/metadata"; import type { HfRepoType } from "@/types"; +import { requireAuth } from "@/lib/auth/server"; const VALID_REPO_TYPES = new Set(["dataset", "model", "space"]); @@ -10,6 +11,11 @@ const VALID_REPO_TYPES = new Set(["dataset", "model", "space"]); */ export async function GET(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const { searchParams } = new URL(request.url); const repoId = searchParams.get("repoId"); const repoTypeParam = searchParams.get("repoType") || "dataset"; diff --git a/src/app/api/datasets/route.ts b/src/app/api/datasets/route.ts index 039c6018..16f7f6b0 100644 --- a/src/app/api/datasets/route.ts +++ b/src/app/api/datasets/route.ts @@ -9,6 +9,8 @@ import { downloadModelScopeRepo } from "@/lib/modelscope/downloader"; import { buildManifest, computeStats } from "@/lib/hf-datasets/manifest"; import { setProgress, markFinished } from "@/lib/hf-datasets/progress"; import type { HfRepoType } from "@/types"; +import { requireAuth } from "@/lib/auth/server"; +import { jsonError, jsonException } from "@/lib/api-errors"; function getDatasetStorageRoot(): string { return process.env.HF_DATASETS_PATH || path.join(process.cwd(), "data", "hf-datasets"); @@ -17,18 +19,23 @@ function getDatasetStorageRoot(): string { /** * GET /api/datasets - List all datasets */ -export async function GET() { +export async function GET(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const rows = await db .select() .from(hfDatasets) + .where(eq(hfDatasets.ownerUserId, auth.user.id)) .orderBy(desc(hfDatasets.createdAt)); const result = rows.map(parseDatasetRow); return NextResponse.json(result); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to list datasets"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list datasets"); } } @@ -37,6 +44,11 @@ export async function GET() { */ export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); const { repoId, @@ -57,12 +69,12 @@ export async function POST(request: NextRequest) { }; if (!repoId) { - return NextResponse.json({ error: "Missing repoId" }, { status: 400 }); + return jsonError("Missing repoId", 400); } const validSources = new Set(["huggingface", "modelscope"]); if (!validSources.has(source)) { - return NextResponse.json({ error: "Invalid source" }, { status: 400 }); + return jsonError("Invalid source", 400); } // Derive display name from repoId @@ -82,6 +94,7 @@ export async function POST(request: NextRequest) { await db.insert(hfDatasets).values({ id, + ownerUserId: auth.user.id, name: displayName, repoId, repoType, @@ -113,8 +126,7 @@ export async function POST(request: NextRequest) { return NextResponse.json(parseDatasetRow(dataset[0]), { status: 201 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create dataset"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to create dataset"); } } diff --git a/src/app/api/deep-research/sessions/[id]/approve/route.ts b/src/app/api/deep-research/sessions/[id]/approve/route.ts index 39baba5e..e30d44b6 100644 --- a/src/app/api/deep-research/sessions/[id]/approve/route.ts +++ b/src/app/api/deep-research/sessions/[id]/approve/route.ts @@ -11,10 +11,15 @@ import { requireSession, type DeepResearchRouteParams, } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; export async function POST(req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const body = await req.json(); if (!isRecord(body) || typeof body.approved !== "boolean") { return NextResponse.json({ error: "Missing nodeId or approved (boolean)" }, { status: 400 }); diff --git a/src/app/api/deep-research/sessions/[id]/artifacts/[artifactId]/route.ts b/src/app/api/deep-research/sessions/[id]/artifacts/[artifactId]/route.ts index b76136b6..93dc4871 100644 --- a/src/app/api/deep-research/sessions/[id]/artifacts/[artifactId]/route.ts +++ b/src/app/api/deep-research/sessions/[id]/artifacts/[artifactId]/route.ts @@ -1,13 +1,19 @@ import { NextRequest, NextResponse } from "next/server"; import { getArtifact } from "@/lib/deep-research/event-store"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type RouteParams = { params: Promise<{ id: string; artifactId: string }> }; -export async function GET(_req: NextRequest, { params }: RouteParams) { - const { artifactId } = await params; +export async function GET(req: NextRequest, { params }: RouteParams) { + const { id: sessionId, artifactId } = await params; + + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const artifact = await getArtifact(artifactId); - if (!artifact) { + if (!artifact || artifact.sessionId !== sessionId) { return NextResponse.json({ error: "Artifact not found" }, { status: 404 }); } diff --git a/src/app/api/deep-research/sessions/[id]/artifacts/route.ts b/src/app/api/deep-research/sessions/[id]/artifacts/route.ts index a29158e2..41be1224 100644 --- a/src/app/api/deep-research/sessions/[id]/artifacts/route.ts +++ b/src/app/api/deep-research/sessions/[id]/artifacts/route.ts @@ -3,11 +3,16 @@ import { getArtifacts } from "@/lib/deep-research/event-store"; import type { ArtifactType } from "@/lib/deep-research/types"; import { ensureInterfaceShell, isInterfaceOnlySession } from "@/lib/deep-research/interface-shell"; import { requireSession } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(req: NextRequest, { params }: RouteParams) { const { id: sessionId } = await params; + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); if (isInterfaceOnlySession(session)) { await ensureInterfaceShell(session); diff --git a/src/app/api/deep-research/sessions/[id]/confirm/route.ts b/src/app/api/deep-research/sessions/[id]/confirm/route.ts index 51766635..e5211e95 100644 --- a/src/app/api/deep-research/sessions/[id]/confirm/route.ts +++ b/src/app/api/deep-research/sessions/[id]/confirm/route.ts @@ -12,6 +12,7 @@ import { requireSession, type DeepResearchRouteParams, } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; const VALID_OUTCOMES: ConfirmationOutcome[] = [ "confirmed", @@ -36,6 +37,10 @@ const EVENT_BY_OUTCOME: Record<ConfirmationOutcome, "user_confirmed" | "user_req export async function POST(req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const body = await req.json(); if (!isRecord(body)) { return NextResponse.json( diff --git a/src/app/api/deep-research/sessions/[id]/events/route.ts b/src/app/api/deep-research/sessions/[id]/events/route.ts index a1bd19dc..6cfe9c95 100644 --- a/src/app/api/deep-research/sessions/[id]/events/route.ts +++ b/src/app/api/deep-research/sessions/[id]/events/route.ts @@ -2,11 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { getEvents } from "@/lib/deep-research/event-store"; import { ensureInterfaceShell, isInterfaceOnlySession } from "@/lib/deep-research/interface-shell"; import { requireSession } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(req: NextRequest, { params }: RouteParams) { const { id: sessionId } = await params; + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); if (isInterfaceOnlySession(session)) { await ensureInterfaceShell(session); diff --git a/src/app/api/deep-research/sessions/[id]/executions/route.ts b/src/app/api/deep-research/sessions/[id]/executions/route.ts index cbf831be..ee2fc11d 100644 --- a/src/app/api/deep-research/sessions/[id]/executions/route.ts +++ b/src/app/api/deep-research/sessions/[id]/executions/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getExecutionRecords } from "@/lib/deep-research/event-store"; import { ensureInterfaceShell, isInterfaceOnlySession } from "@/lib/deep-research/interface-shell"; import { requireSession } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; export async function GET( _req: NextRequest, @@ -9,6 +10,10 @@ export async function GET( ) { const { id: sessionId } = await params; try { + const access = await requireDeepResearchSessionAccess(_req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); if (isInterfaceOnlySession(session)) { await ensureInterfaceShell(session); diff --git a/src/app/api/deep-research/sessions/[id]/export/route.ts b/src/app/api/deep-research/sessions/[id]/export/route.ts index 620d35fd..1964f6c7 100644 --- a/src/app/api/deep-research/sessions/[id]/export/route.ts +++ b/src/app/api/deep-research/sessions/[id]/export/route.ts @@ -6,9 +6,10 @@ import { eq } from "drizzle-orm"; import { writeFile } from "@/lib/files/filesystem"; import path from "path"; import { - extractFinalReportText, + extractFinalReportTextWithFallbackReferences, getLatestFinalReportArtifact, } from "@/lib/deep-research/final-report"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type RouteParams = { params: Promise<{ id: string }> }; @@ -21,6 +22,10 @@ type RouteParams = { params: Promise<{ id: string }> }; export async function POST(req: NextRequest, { params }: RouteParams) { try { const { id: sessionId } = await params; + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const body = await req.json().catch(() => ({})); const customFilename = body.filename as string | undefined; @@ -48,7 +53,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { } // Extract report text from artifact content - const reportText = extractFinalReportText(finalReport); + const reportText = extractFinalReportTextWithFallbackReferences(finalReport, artifacts); // Build the full markdown document with metadata header const now = new Date(); diff --git a/src/app/api/deep-research/sessions/[id]/message/route.test.ts b/src/app/api/deep-research/sessions/[id]/message/route.test.ts index f12624a4..83c3d0e5 100644 --- a/src/app/api/deep-research/sessions/[id]/message/route.test.ts +++ b/src/app/api/deep-research/sessions/[id]/message/route.test.ts @@ -50,6 +50,13 @@ vi.mock("@/lib/deep-research/node-transcript", () => ({ buildNodeTranscriptMetadata: vi.fn(), })); +vi.mock("@/lib/auth/ownership", () => ({ + requireDeepResearchSessionAccess: vi.fn(async () => ({ + auth: { user: { id: "user-1", role: "user" } }, + session: { id: "session-1" }, + })), +})); + vi.mock("@/lib/deep-research/api-helpers", async () => { const { NextResponse } = await import("next/server"); diff --git a/src/app/api/deep-research/sessions/[id]/message/route.ts b/src/app/api/deep-research/sessions/[id]/message/route.ts index 7fd2cd7d..e95b1779 100644 --- a/src/app/api/deep-research/sessions/[id]/message/route.ts +++ b/src/app/api/deep-research/sessions/[id]/message/route.ts @@ -27,6 +27,7 @@ import { type DeepResearchRouteParams, } from "@/lib/deep-research/api-helpers"; import type { CheckpointPackage, ConfirmationOutcome, ModelRole } from "@/lib/deep-research/types"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type NodeMessageRequest = { content: string; @@ -154,6 +155,10 @@ function looksLikeActionableConfirmationFeedback(content: string): boolean { export async function POST(req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); const { content, relatedNodeId, metadata, relatedArtifactIds } = await parseNodeMessageRequest(req); if (!isInterfaceOnlySession(session)) { diff --git a/src/app/api/deep-research/sessions/[id]/messages/route.ts b/src/app/api/deep-research/sessions/[id]/messages/route.ts index 3b478027..90e74b7f 100644 --- a/src/app/api/deep-research/sessions/[id]/messages/route.ts +++ b/src/app/api/deep-research/sessions/[id]/messages/route.ts @@ -2,12 +2,17 @@ import { NextRequest, NextResponse } from "next/server"; import { getMessages } from "@/lib/deep-research/event-store"; import { ensureInterfaceShell, isInterfaceOnlySession } from "@/lib/deep-research/interface-shell"; import { requireSession } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_req: NextRequest, { params }: RouteParams) { try { const { id: sessionId } = await params; + const access = await requireDeepResearchSessionAccess(_req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); if (isInterfaceOnlySession(session)) { await ensureInterfaceShell(session); diff --git a/src/app/api/deep-research/sessions/[id]/nodes/route.ts b/src/app/api/deep-research/sessions/[id]/nodes/route.ts index 8ef5d223..9dd41f0c 100644 --- a/src/app/api/deep-research/sessions/[id]/nodes/route.ts +++ b/src/app/api/deep-research/sessions/[id]/nodes/route.ts @@ -2,11 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { getNodes } from "@/lib/deep-research/event-store"; import { ensureInterfaceShell, isInterfaceOnlySession } from "@/lib/deep-research/interface-shell"; import { requireSession } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; type RouteParams = { params: Promise<{ id: string }> }; export async function GET(_req: NextRequest, { params }: RouteParams) { const { id: sessionId } = await params; + const access = await requireDeepResearchSessionAccess(_req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); if (isInterfaceOnlySession(session)) { await ensureInterfaceShell(session); diff --git a/src/app/api/deep-research/sessions/[id]/route.ts b/src/app/api/deep-research/sessions/[id]/route.ts index 1dd1be17..746cfe4d 100644 --- a/src/app/api/deep-research/sessions/[id]/route.ts +++ b/src/app/api/deep-research/sessions/[id]/route.ts @@ -2,15 +2,21 @@ import { NextRequest, NextResponse } from "next/server"; import { getConfiguredModelSelection } from "@/lib/ai/provider"; import { deleteSession, updateSession } from "@/lib/deep-research/event-store"; import { ensureInterfaceShell, isInterfaceOnlySession } from "@/lib/deep-research/interface-shell"; +import { + buildDeepResearchConfigForResolvedModel, + hasDeepResearchModelConfigDrift, +} from "@/lib/deep-research/model-overrides"; import { runManager } from "@/lib/deep-research/run-manager"; import { handleDeepResearchRouteError, + isRecord, parseNullableString, parseOptionalString, readSessionId, requireSession, type DeepResearchRouteParams, } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; function isLegacyInterfaceShellSession(session: Awaited<ReturnType<typeof requireSession>>): boolean { return session.config.interfaceOnly === true @@ -21,38 +27,33 @@ function isLegacyInterfaceShellSession(session: Awaited<ReturnType<typeof requir export async function GET(_req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(_req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); const configuredModel = await getConfiguredModelSelection(); + const resolvedModel = { + provider: configuredModel.providerId, + modelId: configuredModel.modelId, + }; if (isLegacyInterfaceShellSession(session)) { + const nextConfig = buildDeepResearchConfigForResolvedModel( + session.config, + resolvedModel, + { interfaceOnly: false }, + ); await updateSession(sessionId, { - config: { - ...session.config, - interfaceOnly: false, - resolvedModel: { - provider: configuredModel.providerId, - modelId: configuredModel.modelId, - }, - modelOverrides: undefined, - }, + config: nextConfig, }); return NextResponse.json(await requireSession(sessionId)); } + const nextConfig = buildDeepResearchConfigForResolvedModel(session.config, resolvedModel); const needsModelSync = - session.config.interfaceOnly !== true && ( - session.config.resolvedModel?.provider !== configuredModel.providerId - || session.config.resolvedModel?.modelId !== configuredModel.modelId - || session.config.modelOverrides !== undefined - ); + session.config.interfaceOnly !== true && hasDeepResearchModelConfigDrift(session.config, nextConfig); if (needsModelSync) { await updateSession(sessionId, { - config: { - ...session.config, - resolvedModel: { - provider: configuredModel.providerId, - modelId: configuredModel.modelId, - }, - modelOverrides: undefined, - }, + config: nextConfig, }); return NextResponse.json(await requireSession(sessionId)); } @@ -69,6 +70,10 @@ export async function GET(_req: NextRequest, { params }: DeepResearchRouteParams export async function DELETE(_req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(_req, sessionId); + if (access instanceof NextResponse) { + return access; + } await requireSession(sessionId); if (runManager.isRunning(sessionId)) { @@ -85,7 +90,12 @@ export async function DELETE(_req: NextRequest, { params }: DeepResearchRoutePar export async function PATCH(req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } await requireSession(sessionId); + const configuredModel = await getConfiguredModelSelection(); const body = await req.json(); const updates: Record<string, unknown> = {}; @@ -95,6 +105,23 @@ export async function PATCH(req: NextRequest, { params }: DeepResearchRouteParam if (body.title !== undefined) { updates.title = parseOptionalString(body.title, "Invalid title"); } + if (body.modelOverrides !== undefined) { + if (!isRecord(body.modelOverrides)) { + return NextResponse.json({ error: "Invalid modelOverrides" }, { status: 400 }); + } + const session = await requireSession(sessionId); + const parsedOverrides = parseModelOverridesRecord(body.modelOverrides); + updates.config = buildDeepResearchConfigForResolvedModel( + { + ...session.config, + modelOverrides: parsedOverrides, + }, + session.config.resolvedModel ?? { + provider: configuredModel.providerId, + modelId: configuredModel.modelId, + }, + ); + } if (Object.keys(updates).length === 0) { return NextResponse.json({ error: "No valid fields to update" }, { status: 400 }); @@ -107,3 +134,23 @@ export async function PATCH(req: NextRequest, { params }: DeepResearchRouteParam return handleDeepResearchRouteError(error, "Failed to update session"); } } + +function parseModelOverridesRecord( + value: Record<string, unknown>, +): Record<string, { provider: string; modelId: string }> | undefined { + const parsedEntries = Object.entries(value).flatMap(([roleId, rawOverride]) => { + if (!isRecord(rawOverride)) { + return []; + } + + const provider = parseOptionalString(rawOverride.provider, `Invalid provider for ${roleId}`); + const modelId = parseOptionalString(rawOverride.modelId, `Invalid modelId for ${roleId}`); + if (!provider || !modelId) { + return []; + } + + return [[roleId, { provider, modelId }] as const]; + }); + + return parsedEntries.length > 0 ? Object.fromEntries(parsedEntries) : undefined; +} diff --git a/src/app/api/deep-research/sessions/[id]/run/route.ts b/src/app/api/deep-research/sessions/[id]/run/route.ts index 7946905b..3dbf48e7 100644 --- a/src/app/api/deep-research/sessions/[id]/run/route.ts +++ b/src/app/api/deep-research/sessions/[id]/run/route.ts @@ -10,10 +10,15 @@ import { requireSession, type DeepResearchRouteParams, } from "@/lib/deep-research/api-helpers"; +import { requireDeepResearchSessionAccess } from "@/lib/auth/ownership"; export async function POST(req: NextRequest, { params }: DeepResearchRouteParams) { try { const sessionId = await readSessionId(params); + const access = await requireDeepResearchSessionAccess(req, sessionId); + if (access instanceof NextResponse) { + return access; + } const session = await requireSession(sessionId); if (isInterfaceOnlySession(session)) { await ensureInterfaceShell(session); diff --git a/src/app/api/deep-research/sessions/route.ts b/src/app/api/deep-research/sessions/route.ts index 57d80079..29220768 100644 --- a/src/app/api/deep-research/sessions/route.ts +++ b/src/app/api/deep-research/sessions/route.ts @@ -7,6 +7,7 @@ import { parseRequiredString, parseOptionalStringArray, } from "@/lib/deep-research/api-helpers"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function GET(req: NextRequest) { try { @@ -14,6 +15,10 @@ export async function GET(req: NextRequest) { req.nextUrl.searchParams.get("workspaceId"), "Missing workspaceId", ); + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } const sessions = await listSessions(workspaceId); return NextResponse.json(sessions); } catch (error) { @@ -25,6 +30,10 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); const workspaceId = parseRequiredString(body.workspaceId, "Missing workspaceId"); + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } const title = parseRequiredString(body.title, "Missing title"); const content = parseOptionalString(body.content, "Invalid content"); const files = parseOptionalStringArray(body.files, "Invalid files"); diff --git a/src/app/api/files/browse/route.ts b/src/app/api/files/browse/route.ts index 3b1a0fe9..fe7c06a1 100644 --- a/src/app/api/files/browse/route.ts +++ b/src/app/api/files/browse/route.ts @@ -1,16 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { listDirectory, addWorkspaceRoot } from "@/lib/files/filesystem"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonException, requiredSearchParam } from "@/lib/api-errors"; export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const dirPath = searchParams.get("path"); + const dirPath = requiredSearchParam(request, "path", "Missing path parameter"); + if (dirPath instanceof NextResponse) { + return dirPath; + } - if (!dirPath) { - return NextResponse.json( - { error: "Missing path parameter" }, - { status: 400 } - ); + const access = await requirePathAccess(request, dirPath); + if (access instanceof NextResponse) { + return access; } // Auto-register as workspace root if not already covered @@ -19,9 +21,6 @@ export async function GET(request: NextRequest) { const entries = await listDirectory(dirPath); return NextResponse.json(entries); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to browse directory"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to browse directory"); } } diff --git a/src/app/api/files/copy/route.ts b/src/app/api/files/copy/route.ts index fa021af1..52b441f0 100644 --- a/src/app/api/files/copy/route.ts +++ b/src/app/api/files/copy/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { copyFileOrDir } from "@/lib/files/filesystem"; +import { requireWorkspacePathsAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { const { sourcePath, destPath } = await request.json(); if (!sourcePath || !destPath || typeof sourcePath !== "string" || typeof destPath !== "string") { - return NextResponse.json( - { error: "sourcePath and destPath must be non-empty strings" }, - { status: 400 } - ); + return jsonError("sourcePath and destPath must be non-empty strings", 400); + } + + const access = await requireWorkspacePathsAccess(request, [sourcePath, destPath]); + if (access instanceof NextResponse) { + return access; } await copyFileOrDir(sourcePath, destPath); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to copy"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to copy"); } } diff --git a/src/app/api/files/delete/route.ts b/src/app/api/files/delete/route.ts index 33395bd2..5f7d292a 100644 --- a/src/app/api/files/delete/route.ts +++ b/src/app/api/files/delete/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { deleteFile } from "@/lib/files/filesystem"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { const { path: filePath } = await request.json(); if (!filePath) { - return NextResponse.json( - { error: "Missing path" }, - { status: 400 } - ); + return jsonError("Missing path", 400); + } + + const access = await requirePathAccess(request, filePath); + if (access instanceof NextResponse) { + return access; } await deleteFile(filePath); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to delete"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to delete"); } } diff --git a/src/app/api/files/extract-article/route.ts b/src/app/api/files/extract-article/route.ts index d91330cb..db7a3f29 100644 --- a/src/app/api/files/extract-article/route.ts +++ b/src/app/api/files/extract-article/route.ts @@ -8,6 +8,7 @@ import { validatePath } from "@/lib/files/filesystem"; import { jsonError } from "@/lib/api-errors"; import { logAndIgnore } from "@/lib/utils/log"; import { PAPER } from "@/lib/constants"; +import { requirePathAccess } from "@/lib/auth/ownership"; import type { Article } from "@/lib/article-search/types"; /** @@ -48,6 +49,11 @@ export async function POST(req: NextRequest) { return jsonError("Missing filePath", 400); } + const access = await requirePathAccess(req, filePath); + if (access instanceof NextResponse) { + return access; + } + // Security: validate path is within workspace roots const validated = validatePath(filePath); diff --git a/src/app/api/files/mkdir/route.ts b/src/app/api/files/mkdir/route.ts index 42c1b1a7..e4e89abd 100644 --- a/src/app/api/files/mkdir/route.ts +++ b/src/app/api/files/mkdir/route.ts @@ -1,26 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; -import { createDirectory, addWorkspaceRoot } from "@/lib/files/filesystem"; +import { createDirectory } from "@/lib/files/filesystem"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { const { path: dirPath } = await request.json(); if (!dirPath) { - return NextResponse.json( - { error: "Missing path" }, - { status: 400 } - ); + return jsonError("Missing path", 400); } - // Auto-register parent as workspace root if not already covered - addWorkspaceRoot(dirPath); + const access = await requirePathAccess(request, dirPath); + if (access instanceof NextResponse) { + return access; + } await createDirectory(dirPath); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to create directory"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to create directory"); } } diff --git a/src/app/api/files/move/route.ts b/src/app/api/files/move/route.ts index 6be00651..b60bc0d8 100644 --- a/src/app/api/files/move/route.ts +++ b/src/app/api/files/move/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { renameFile } from "@/lib/files/filesystem"; +import { requireWorkspacePathsAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { const { sourcePath, destPath } = await request.json(); if (!sourcePath || !destPath || typeof sourcePath !== "string" || typeof destPath !== "string") { - return NextResponse.json( - { error: "sourcePath and destPath must be non-empty strings" }, - { status: 400 } - ); + return jsonError("sourcePath and destPath must be non-empty strings", 400); + } + + const access = await requireWorkspacePathsAccess(request, [sourcePath, destPath]); + if (access instanceof NextResponse) { + return access; } await renameFile(sourcePath, destPath); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to move"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to move"); } } diff --git a/src/app/api/files/raw/route.ts b/src/app/api/files/raw/route.ts index b42612d0..260edbed 100644 --- a/src/app/api/files/raw/route.ts +++ b/src/app/api/files/raw/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import path from "path"; import { readFileBuffer } from "@/lib/files/filesystem"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonException, requiredSearchParam } from "@/lib/api-errors"; const MIME_TYPES: Record<string, string> = { pdf: "application/pdf", @@ -28,14 +30,14 @@ const MIME_TYPES: Record<string, string> = { export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const filePath = searchParams.get("path"); + const filePath = requiredSearchParam(request, "path", "Missing path parameter"); + if (filePath instanceof NextResponse) { + return filePath; + } - if (!filePath) { - return NextResponse.json( - { error: "Missing path parameter" }, - { status: 400 } - ); + const access = await requirePathAccess(request, filePath); + if (access instanceof NextResponse) { + return access; } const buffer = await readFileBuffer(filePath); @@ -51,9 +53,6 @@ export async function GET(request: NextRequest) { }, }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to read file"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to read file"); } } diff --git a/src/app/api/files/read/route.ts b/src/app/api/files/read/route.ts index 81418a53..daebb6eb 100644 --- a/src/app/api/files/read/route.ts +++ b/src/app/api/files/read/route.ts @@ -1,24 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; import { readFile } from "@/lib/files/filesystem"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonException, requiredSearchParam } from "@/lib/api-errors"; export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const filePath = searchParams.get("path"); + const filePath = requiredSearchParam(request, "path", "Missing path parameter"); + if (filePath instanceof NextResponse) { + return filePath; + } - if (!filePath) { - return NextResponse.json( - { error: "Missing path parameter" }, - { status: 400 } - ); + const access = await requirePathAccess(request, filePath); + if (access instanceof NextResponse) { + return access; } const content = await readFile(filePath); return NextResponse.json({ content }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to read file"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to read file"); } } diff --git a/src/app/api/files/rename/route.ts b/src/app/api/files/rename/route.ts index def65462..51164ae2 100644 --- a/src/app/api/files/rename/route.ts +++ b/src/app/api/files/rename/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { renameFile } from "@/lib/files/filesystem"; +import { requireWorkspacePathsAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { const { oldPath, newPath } = await request.json(); if (!oldPath || !newPath) { - return NextResponse.json( - { error: "Missing oldPath or newPath" }, - { status: 400 } - ); + return jsonError("Missing oldPath or newPath", 400); + } + + const access = await requireWorkspacePathsAccess(request, [oldPath, newPath]); + if (access instanceof NextResponse) { + return access; } await renameFile(oldPath, newPath); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to rename"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to rename"); } } diff --git a/src/app/api/files/upload/route.ts b/src/app/api/files/upload/route.ts index 856059c9..c7677219 100644 --- a/src/app/api/files/upload/route.ts +++ b/src/app/api/files/upload/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { uploadFile } from "@/lib/files/filesystem"; import path from "path"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { @@ -9,10 +11,12 @@ export async function POST(request: NextRequest) { const targetDir = formData.get("targetDir") as string | null; if (!file || !targetDir) { - return NextResponse.json( - { error: "Missing file or targetDir" }, - { status: 400 } - ); + return jsonError("Missing file or targetDir", 400); + } + + const access = await requirePathAccess(request, targetDir); + if (access instanceof NextResponse) { + return access; } const buffer = Buffer.from(await file.arrayBuffer()); @@ -21,9 +25,6 @@ export async function POST(request: NextRequest) { await uploadFile(filePath, buffer); return NextResponse.json({ success: true, path: filePath }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to upload file"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to upload file"); } } diff --git a/src/app/api/files/write/route.ts b/src/app/api/files/write/route.ts index 5347efb5..a1c5326e 100644 --- a/src/app/api/files/write/route.ts +++ b/src/app/api/files/write/route.ts @@ -1,23 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { writeFile } from "@/lib/files/filesystem"; +import { requirePathAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function POST(request: NextRequest) { try { const { path: filePath, content } = await request.json(); if (!filePath || content === undefined) { - return NextResponse.json( - { error: "Missing path or content" }, - { status: 400 } - ); + return jsonError("Missing path or content", 400); + } + + const access = await requirePathAccess(request, filePath); + if (access instanceof NextResponse) { + return access; } await writeFile(filePath, content); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to write file"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to write file"); } } diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 02f9f904..93aacfc8 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -6,6 +6,7 @@ import { db } from "@/lib/db"; import { sources, notes } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { nanoid } from "nanoid"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function POST(request: NextRequest) { try { @@ -17,6 +18,10 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } if (!isAIAvailable()) { return NextResponse.json( diff --git a/src/app/api/git/pull/route.ts b/src/app/api/git/pull/route.ts index bb73e6f0..220b1d58 100644 --- a/src/app/api/git/pull/route.ts +++ b/src/app/api/git/pull/route.ts @@ -3,6 +3,7 @@ import { pullRepo } from "@/lib/git/github"; import { db } from "@/lib/db"; import { workspaces } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function POST(request: NextRequest) { try { @@ -14,6 +15,10 @@ export async function POST(request: NextRequest) { { status: 400 } ); } + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } const workspace = await db .select() diff --git a/src/app/api/git/status/route.ts b/src/app/api/git/status/route.ts index 69a6479f..044b0a5c 100644 --- a/src/app/api/git/status/route.ts +++ b/src/app/api/git/status/route.ts @@ -3,6 +3,7 @@ import { getGitStatus } from "@/lib/git/github"; import { db } from "@/lib/db"; import { workspaces } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function GET(request: NextRequest) { try { @@ -15,6 +16,10 @@ export async function GET(request: NextRequest) { { status: 400 } ); } + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } const workspace = await db .select() diff --git a/src/app/api/notes/[noteId]/route.ts b/src/app/api/notes/[noteId]/route.ts index 80fb0554..24206532 100644 --- a/src/app/api/notes/[noteId]/route.ts +++ b/src/app/api/notes/[noteId]/route.ts @@ -2,13 +2,18 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { notes } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; +import { requireNoteAccess } from "@/lib/auth/ownership"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ noteId: string }> } ) { try { const { noteId } = await params; + const access = await requireNoteAccess(request, noteId); + if (access instanceof NextResponse) { + return access; + } const note = await db .select() @@ -34,6 +39,10 @@ export async function PATCH( ) { try { const { noteId } = await params; + const access = await requireNoteAccess(request, noteId); + if (access instanceof NextResponse) { + return access; + } const body = await request.json(); await db @@ -59,11 +68,15 @@ export async function PATCH( } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ noteId: string }> } ) { try { const { noteId } = await params; + const access = await requireNoteAccess(request, noteId); + if (access instanceof NextResponse) { + return access; + } await db.delete(notes).where(eq(notes.id, noteId)); diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 510c07f7..653ae622 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -3,17 +3,19 @@ import { db } from "@/lib/db"; import { notes } from "@/lib/db/schema"; import { eq, desc } from "drizzle-orm"; import { nanoid } from "nanoid"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException, requiredSearchParam } from "@/lib/api-errors"; export async function GET(request: NextRequest) { try { - const { searchParams } = new URL(request.url); - const workspaceId = searchParams.get("workspaceId"); + const workspaceId = requiredSearchParam(request, "workspaceId"); + if (workspaceId instanceof NextResponse) { + return workspaceId; + } - if (!workspaceId) { - return NextResponse.json( - { error: "Missing workspaceId" }, - { status: 400 } - ); + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; } const allNotes = await db @@ -24,9 +26,7 @@ export async function GET(request: NextRequest) { return NextResponse.json(allNotes); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to list notes"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list notes"); } } @@ -36,10 +36,12 @@ export async function POST(request: NextRequest) { const { workspaceId, title, content, type } = body; if (!workspaceId || !title) { - return NextResponse.json( - { error: "Missing required fields" }, - { status: 400 } - ); + return jsonError("Missing required fields", 400); + } + + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; } const id = nanoid(); @@ -63,8 +65,6 @@ export async function POST(request: NextRequest) { return NextResponse.json(note[0], { status: 201 }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to create note"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to create note"); } } diff --git a/src/app/api/paper-study/extract-paper-content-runtime.test.ts b/src/app/api/paper-study/extract-paper-content-runtime.test.ts new file mode 100644 index 00000000..0cf96ba9 --- /dev/null +++ b/src/app/api/paper-study/extract-paper-content-runtime.test.ts @@ -0,0 +1,25 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("extract-paper-content startup", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("does not load PDF helpers during module import", async () => { + vi.doMock("@/lib/article-search/paper-content", () => { + throw new Error("paper-content should not load during module import"); + }); + vi.doMock("@/lib/files/pdf-image-extractor", () => { + throw new Error("pdf-image-extractor should not load during module import"); + }); + vi.doMock("@/lib/files/text-extractor", () => { + throw new Error("text-extractor should not load during module import"); + }); + + const imported = await import("./extract-paper-content"); + + expect(imported.extractPaperContent).toEqual(expect.any(Function)); + expect(imported.extractPaperFullText).toEqual(expect.any(Function)); + }); +}); diff --git a/src/app/api/paper-study/extract-paper-content.ts b/src/app/api/paper-study/extract-paper-content.ts index 53dd0dfd..95e26304 100644 --- a/src/app/api/paper-study/extract-paper-content.ts +++ b/src/app/api/paper-study/extract-paper-content.ts @@ -1,11 +1,5 @@ import { validatePath } from "@/lib/files/filesystem"; -import { extractText, isSupportedFile, normalizeText } from "@/lib/files/text-extractor"; -import { resolvePaperPdfBuffer } from "@/lib/article-search/paper-content"; -import { - extractPdfPagesWithImages, - extractPdfPagesTextOnly, - type PaperContentPart, -} from "@/lib/files/pdf-image-extractor"; +import type { PaperContentPart } from "@/lib/files/pdf-image-extractor"; import type { PaperArticleRef } from "./article-ref"; // Re-export for convenience @@ -25,9 +19,16 @@ export async function extractPaperContent( maxPages: number = 20, ): Promise<PaperContentPart[] | undefined> { try { + const { resolvePaperPdfBuffer } = await import( + "@/lib/article-search/paper-content" + ); const buffer = await resolvePaperPdfBuffer(article); if (buffer) { + const { + extractPdfPagesWithImages, + extractPdfPagesTextOnly, + } = await import("@/lib/files/pdf-image-extractor"); // PDF path — extract with or without images const parts = withImages ? await extractPdfPagesWithImages(buffer, { maxPages }) @@ -37,6 +38,9 @@ export async function extractPaperContent( // Non-PDF local file — text only if (article.source === "local") { + const { extractText, isSupportedFile, normalizeText } = await import( + "@/lib/files/text-extractor" + ); const filePath = validatePath(article.url); if (!isSupportedFile(filePath)) return undefined; const rawText = await extractText(filePath); diff --git a/src/app/api/paper-study/extract-paper-text-runtime.test.ts b/src/app/api/paper-study/extract-paper-text-runtime.test.ts new file mode 100644 index 00000000..2961ec4f --- /dev/null +++ b/src/app/api/paper-study/extract-paper-text-runtime.test.ts @@ -0,0 +1,19 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("extract-paper-text startup", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("does not load the remote paper fetcher during module import", async () => { + vi.doMock("@/lib/paper-study/remote-paper-fetcher", () => { + throw new Error("remote-paper-fetcher should not load during module import"); + }); + + const imported = await import("./extract-paper-text"); + + expect(imported.extractPaperFullText).toEqual(expect.any(Function)); + expect(imported.extractPaperFullContent).toEqual(expect.any(Function)); + }); +}); diff --git a/src/app/api/paper-study/extract-paper-text.ts b/src/app/api/paper-study/extract-paper-text.ts index 50851f05..6a75196b 100644 --- a/src/app/api/paper-study/extract-paper-text.ts +++ b/src/app/api/paper-study/extract-paper-text.ts @@ -1,9 +1,5 @@ -import { extractText, isSupportedFile, normalizeText } from "@/lib/files/text-extractor"; import { validatePath } from "@/lib/files/filesystem"; -import { - fetchRemotePaperContent, - type RemotePaperContent, -} from "@/lib/paper-study/remote-paper-fetcher"; +import type { RemotePaperContent } from "@/lib/paper-study/remote-paper-fetcher"; import type { PaperArticleRef } from "./article-ref"; /** @@ -19,6 +15,9 @@ export async function extractPaperFullText( ): Promise<string | undefined> { if (article.source === "local") { try { + const { extractText, isSupportedFile, normalizeText } = await import( + "@/lib/files/text-extractor" + ); const filePath = validatePath(article.url); if (!isSupportedFile(filePath)) return undefined; @@ -34,6 +33,9 @@ export async function extractPaperFullText( // Remote sources: arXiv, Semantic Scholar, HuggingFace, etc. try { + const { fetchRemotePaperContent } = await import( + "@/lib/paper-study/remote-paper-fetcher" + ); const result = await fetchRemotePaperContent(article, maxChars); return result.fullText || undefined; } catch { @@ -51,6 +53,9 @@ export async function extractPaperFullContent( ): Promise<RemotePaperContent> { if (article.source === "local") { try { + const { extractText, isSupportedFile, normalizeText } = await import( + "@/lib/files/text-extractor" + ); const filePath = validatePath(article.url); if (!isSupportedFile(filePath)) { return { fullText: "", figures: [], source: "none" }; @@ -69,6 +74,9 @@ export async function extractPaperFullContent( } try { + const { fetchRemotePaperContent } = await import( + "@/lib/paper-study/remote-paper-fetcher" + ); return await fetchRemotePaperContent(article, maxChars); } catch { return { fullText: "", figures: [], source: "none" }; diff --git a/src/app/api/research-exec/capabilities/route.ts b/src/app/api/research-exec/capabilities/route.ts index 7cd7614a..08fa9b27 100644 --- a/src/app/api/research-exec/capabilities/route.ts +++ b/src/app/api/research-exec/capabilities/route.ts @@ -1,11 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { getCapabilities, setCapability } from "@/lib/research-exec/capabilities"; import { CAPABILITY_KEYS, type CapabilityFlags } from "@/lib/research-exec/types"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException, requiredSearchParam } from "@/lib/api-errors"; export async function GET(req: NextRequest) { - const workspaceId = req.nextUrl.searchParams.get("workspaceId"); - if (!workspaceId) { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + const workspaceId = requiredSearchParam(req, "workspaceId"); + if (workspaceId instanceof NextResponse) { + return workspaceId; + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } const caps = await getCapabilities(workspaceId); @@ -17,25 +23,25 @@ export async function PATCH(req: NextRequest) { const { workspaceId, flag, value } = await req.json(); if (!workspaceId || typeof workspaceId !== "string") { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + return jsonError("Missing workspaceId", 400); + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } if (!flag || !CAPABILITY_KEYS.includes(flag as keyof CapabilityFlags)) { - return NextResponse.json( - { error: `Invalid flag. Must be one of: ${CAPABILITY_KEYS.join(", ")}` }, - { status: 400 }, - ); + return jsonError(`Invalid flag. Must be one of: ${CAPABILITY_KEYS.join(", ")}`, 400); } if (typeof value !== "boolean") { - return NextResponse.json({ error: "value must be a boolean" }, { status: 400 }); + return jsonError("value must be a boolean", 400); } await setCapability(workspaceId, flag as keyof CapabilityFlags, value); const updated = await getCapabilities(workspaceId); return NextResponse.json(updated); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to update capability"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to update capability"); } } diff --git a/src/app/api/research-exec/profiles/route.ts b/src/app/api/research-exec/profiles/route.ts index 08f41543..dc1acab4 100644 --- a/src/app/api/research-exec/profiles/route.ts +++ b/src/app/api/research-exec/profiles/route.ts @@ -3,11 +3,17 @@ import { db } from "@/lib/db"; import { remoteProfiles } from "@/lib/db/schema"; import { eq, and } from "drizzle-orm"; import { randomUUID } from "crypto"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException, requiredSearchParam } from "@/lib/api-errors"; export async function GET(req: NextRequest) { - const workspaceId = req.nextUrl.searchParams.get("workspaceId"); - if (!workspaceId) { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + const workspaceId = requiredSearchParam(req, "workspaceId"); + if (workspaceId instanceof NextResponse) { + return workspaceId; + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } const profiles = await db @@ -24,10 +30,11 @@ export async function POST(req: NextRequest) { const { workspaceId, name, host, port, username, remotePath, schedulerType, sshKeyRef, pollIntervalSeconds, rjobConfig } = body; if (!workspaceId || !name || !host || !username || !remotePath) { - return NextResponse.json( - { error: "Missing required fields: workspaceId, name, host, username, remotePath" }, - { status: 400 }, - ); + return jsonError("Missing required fields: workspaceId, name, host, username, remotePath", 400); + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } const id = randomUUID(); @@ -56,8 +63,7 @@ export async function POST(req: NextRequest) { return NextResponse.json(created, { status: 201 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create profile"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to create profile"); } } @@ -67,7 +73,11 @@ export async function PATCH(req: NextRequest) { const { id, workspaceId, name, host, port, username, remotePath, schedulerType, sshKeyRef, pollIntervalSeconds, rjobConfig } = body; if (!id || !workspaceId) { - return NextResponse.json({ error: "Missing id or workspaceId" }, { status: 400 }); + return jsonError("Missing id or workspaceId", 400); + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } const now = new Date().toISOString(); @@ -94,8 +104,7 @@ export async function PATCH(req: NextRequest) { return NextResponse.json(updated); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to update profile"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to update profile"); } } @@ -104,7 +113,11 @@ export async function DELETE(req: NextRequest) { const workspaceId = req.nextUrl.searchParams.get("workspaceId"); if (!id || !workspaceId) { - return NextResponse.json({ error: "Missing id or workspaceId" }, { status: 400 }); + return jsonError("Missing id or workspaceId", 400); + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } await db diff --git a/src/app/api/research-exec/runs/route.ts b/src/app/api/research-exec/runs/route.ts index a2c23ead..eb403e50 100644 --- a/src/app/api/research-exec/runs/route.ts +++ b/src/app/api/research-exec/runs/route.ts @@ -3,11 +3,17 @@ import { db } from "@/lib/db"; import { experimentRuns } from "@/lib/db/schema"; import { eq, desc } from "drizzle-orm"; import { randomUUID } from "crypto"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException, requiredSearchParam } from "@/lib/api-errors"; export async function GET(req: NextRequest) { - const workspaceId = req.nextUrl.searchParams.get("workspaceId"); - if (!workspaceId) { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + const workspaceId = requiredSearchParam(req, "workspaceId"); + if (workspaceId instanceof NextResponse) { + return workspaceId; + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } const runs = await db @@ -33,7 +39,11 @@ export async function POST(req: NextRequest) { const { workspaceId, remoteProfileId } = await req.json(); if (!workspaceId) { - return NextResponse.json({ error: "Missing workspaceId" }, { status: 400 }); + return jsonError("Missing workspaceId", 400); + } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; } const id = randomUUID(); @@ -55,7 +65,6 @@ export async function POST(req: NextRequest) { return NextResponse.json(created, { status: 201 }); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to create run"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to create run"); } } diff --git a/src/app/api/scheduled-tasks/[taskId]/route.ts b/src/app/api/scheduled-tasks/[taskId]/route.ts index fa3aac1d..e020afa0 100644 --- a/src/app/api/scheduled-tasks/[taskId]/route.ts +++ b/src/app/api/scheduled-tasks/[taskId]/route.ts @@ -3,6 +3,8 @@ import { db } from "@/lib/db"; import { scheduledTasks } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { isValidCron } from "@/lib/scheduler"; +import { requireScheduledTaskAccess, requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; const VALID_TASK_TYPES = [ "daily_report", @@ -19,57 +21,46 @@ export async function PATCH( ) { try { const { taskId } = await params; - - const existing = await db - .select() - .from(scheduledTasks) - .where(eq(scheduledTasks.id, taskId)) - .limit(1); - - if (existing.length === 0) { - return NextResponse.json({ error: "Task not found" }, { status: 404 }); + const access = await requireScheduledTaskAccess(request, taskId); + if (access instanceof NextResponse) { + return access; } + const { task } = access; const body = await request.json(); const updates: Record<string, unknown> = {}; if (body.name !== undefined) { if (typeof body.name !== "string" || !body.name.trim()) { - return NextResponse.json( - { error: "name must be a non-empty string" }, - { status: 400 } - ); + return jsonError("name must be a non-empty string", 400); } updates.name = body.name.trim(); } if (body.taskType !== undefined) { if (!VALID_TASK_TYPES.includes(body.taskType)) { - return NextResponse.json( - { error: `taskType must be one of: ${VALID_TASK_TYPES.join(", ")}` }, - { status: 400 } - ); + return jsonError(`taskType must be one of: ${VALID_TASK_TYPES.join(", ")}`, 400); } updates.taskType = body.taskType; } if (body.schedule !== undefined) { if (typeof body.schedule !== "string" || !body.schedule.trim()) { - return NextResponse.json( - { error: "schedule must be a non-empty string" }, - { status: 400 } - ); + return jsonError("schedule must be a non-empty string", 400); } if (!isValidCron(body.schedule)) { - return NextResponse.json( - { error: "Invalid cron expression" }, - { status: 400 } - ); + return jsonError("Invalid cron expression", 400); } updates.schedule = body.schedule.trim(); } if (body.workspaceId !== undefined) { + if (body.workspaceId) { + const access = await requireWorkspaceAccess(request, body.workspaceId); + if (access instanceof NextResponse) { + return access; + } + } updates.workspaceId = body.workspaceId || null; } @@ -78,10 +69,7 @@ export async function PATCH( try { JSON.parse(body.config); } catch { - return NextResponse.json( - { error: "config must be valid JSON" }, - { status: 400 } - ); + return jsonError("config must be valid JSON", 400); } } updates.config = @@ -99,48 +87,37 @@ export async function PATCH( await db .update(scheduledTasks) .set(updates) - .where(eq(scheduledTasks.id, taskId)); + .where(eq(scheduledTasks.id, task.id)); const [updated] = await db .select() .from(scheduledTasks) - .where(eq(scheduledTasks.id, taskId)) + .where(eq(scheduledTasks.id, task.id)) .limit(1); return NextResponse.json(updated); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to update scheduled task"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to update scheduled task"); } } /** DELETE /api/scheduled-tasks/[taskId] — delete a scheduled task */ export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ taskId: string }> } ) { try { const { taskId } = await params; - - const existing = await db - .select() - .from(scheduledTasks) - .where(eq(scheduledTasks.id, taskId)) - .limit(1); - - if (existing.length === 0) { - return NextResponse.json({ error: "Task not found" }, { status: 404 }); + const access = await requireScheduledTaskAccess(request, taskId); + if (access instanceof NextResponse) { + return access; } + const { task } = access; - await db.delete(scheduledTasks).where(eq(scheduledTasks.id, taskId)); + await db.delete(scheduledTasks).where(eq(scheduledTasks.id, task.id)); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error - ? error.message - : "Failed to delete scheduled task"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to delete scheduled task"); } } diff --git a/src/app/api/scheduled-tasks/route.ts b/src/app/api/scheduled-tasks/route.ts index 48dd3a81..2d3237c4 100644 --- a/src/app/api/scheduled-tasks/route.ts +++ b/src/app/api/scheduled-tasks/route.ts @@ -3,6 +3,10 @@ import { db } from "@/lib/db"; import { scheduledTasks } from "@/lib/db/schema"; import { isValidCron } from "@/lib/scheduler"; import crypto from "crypto"; +import { requireAuth } from "@/lib/auth/server"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; +import { ownedScheduledTaskFilter } from "@/lib/auth/ownership"; const VALID_TASK_TYPES = [ "daily_report", @@ -13,14 +17,20 @@ const VALID_TASK_TYPES = [ ] as const; /** GET /api/scheduled-tasks — list all scheduled tasks */ -export async function GET() { +export async function GET(request: NextRequest) { try { - const tasks = await db.select().from(scheduledTasks); + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const tasks = await db + .select() + .from(scheduledTasks) + .where(ownedScheduledTaskFilter(auth)); return NextResponse.json(tasks); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to list scheduled tasks"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list scheduled tasks"); } } @@ -28,36 +38,34 @@ export async function GET() { export async function POST(request: NextRequest) { try { const body = await request.json(); + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } const { name, taskType, schedule, workspaceId, config } = body; + if (workspaceId) { + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } + } // Validate required fields if (!name || typeof name !== "string" || !name.trim()) { - return NextResponse.json( - { error: "name is required" }, - { status: 400 } - ); + return jsonError("name is required", 400); } if (!taskType || !VALID_TASK_TYPES.includes(taskType)) { - return NextResponse.json( - { error: `taskType must be one of: ${VALID_TASK_TYPES.join(", ")}` }, - { status: 400 } - ); + return jsonError(`taskType must be one of: ${VALID_TASK_TYPES.join(", ")}`, 400); } if (!schedule || typeof schedule !== "string" || !schedule.trim()) { - return NextResponse.json( - { error: "schedule is required" }, - { status: 400 } - ); + return jsonError("schedule is required", 400); } if (!isValidCron(schedule)) { - return NextResponse.json( - { error: "Invalid cron expression" }, - { status: 400 } - ); + return jsonError("Invalid cron expression", 400); } // Validate config is valid JSON if provided @@ -66,10 +74,7 @@ export async function POST(request: NextRequest) { try { JSON.parse(config); } catch { - return NextResponse.json( - { error: "config must be valid JSON" }, - { status: 400 } - ); + return jsonError("config must be valid JSON", 400); } } } @@ -80,6 +85,7 @@ export async function POST(request: NextRequest) { const newTask = { id, name: name.trim(), + ownerUserId: auth.user.id, taskType: taskType as (typeof VALID_TASK_TYPES)[number], schedule: schedule.trim(), workspaceId: workspaceId || null, @@ -98,8 +104,6 @@ export async function POST(request: NextRequest) { return NextResponse.json(newTask, { status: 201 }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to create scheduled task"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to create scheduled task"); } } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 91988857..d4643568 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -9,6 +9,7 @@ import { updateEnvLocal } from "@/lib/env-file"; import { PROVIDERS } from "@/lib/ai/models"; import { getCurrentEnv } from "@/lib/ai/provider-env"; import { getK8sConfig, SETTINGS_TO_ENV, invalidateK8sConfigCache } from "@/lib/cluster/config"; +import { requireAdmin } from "@/lib/auth/server"; /** * Derive the base-URL env var name for a provider (e.g. "openai" → "OPENAI_BASE_URL"). @@ -33,8 +34,13 @@ function getProviderEnvInfo() { return { providerKeys, providerBaseUrls }; } -export async function GET() { +export async function GET(request: NextRequest) { try { + const auth = await requireAdmin(request); + if (auth instanceof NextResponse) { + return auth; + } + const settings = await db.select().from(appSettings); const settingsMap: Record<string, string> = {}; @@ -92,6 +98,11 @@ export async function GET() { export async function PATCH(request: NextRequest) { try { + const auth = await requireAdmin(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); for (const [key, value] of Object.entries(body)) { diff --git a/src/app/api/skills/[skillId]/export/route.ts b/src/app/api/skills/[skillId]/export/route.ts index c3b32419..6848b712 100644 --- a/src/app/api/skills/[skillId]/export/route.ts +++ b/src/app/api/skills/[skillId]/export/route.ts @@ -1,30 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { skills } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; import { skillToMarkdown } from "@/lib/utils/skill-md"; +import { requireSkillAccess } from "@/lib/auth/ownership"; +import { jsonException } from "@/lib/api-errors"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ skillId: string }> } ) { try { const { skillId } = await params; - - const skill = await db - .select() - .from(skills) - .where(eq(skills.id, skillId)) - .limit(1); - - if (skill.length === 0) { - return NextResponse.json( - { error: "Skill not found" }, - { status: 404 } - ); + const access = await requireSkillAccess(request, skillId); + if (access instanceof NextResponse) { + return access; } - const row = skill[0]; + const row = access.skill; const skillData = { name: row.name, @@ -53,8 +43,6 @@ export async function GET( }, }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to export skill"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to export skill"); } } diff --git a/src/app/api/skills/[skillId]/route.ts b/src/app/api/skills/[skillId]/route.ts index 603c81d5..1828a291 100644 --- a/src/app/api/skills/[skillId]/route.ts +++ b/src/app/api/skills/[skillId]/route.ts @@ -4,32 +4,22 @@ import { skills } from "@/lib/db/schema"; import { eq, ne, and, isNull } from "drizzle-orm"; import { slugify } from "@/lib/utils/slugify"; import { parseSkillRow } from "@/lib/db/skills-utils"; +import { requireSkillAccess } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ skillId: string }> } ) { try { const { skillId } = await params; - - const skill = await db - .select() - .from(skills) - .where(eq(skills.id, skillId)) - .limit(1); - - if (skill.length === 0) { - return NextResponse.json( - { error: "Skill not found" }, - { status: 404 } - ); + const access = await requireSkillAccess(request, skillId); + if (access instanceof NextResponse) { + return access; } - - return NextResponse.json(parseSkillRow(skill[0])); + return NextResponse.json(parseSkillRow(access.skill)); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to get skill"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to get skill"); } } @@ -39,6 +29,11 @@ export async function PATCH( ) { try { const { skillId } = await params; + const access = await requireSkillAccess(request, skillId); + if (access instanceof NextResponse) { + return access; + } + const { skill: currentSkill } = access; const body = await request.json(); // JSON-serialize nested fields before writing @@ -50,39 +45,25 @@ export async function PATCH( if (body.slug !== undefined) { const normalizedSlug = slugify(body.slug); if (!normalizedSlug) { - return NextResponse.json( - { error: "Invalid slug: slug must contain at least one alphanumeric character after normalization" }, - { status: 400 } - ); + return jsonError("Invalid slug: slug must contain at least one alphanumeric character after normalization", 400); } - // Get the existing skill to determine scope for uniqueness check - const existingSkill = await db + const duplicate = await db .select() .from(skills) - .where(eq(skills.id, skillId)) + .where( + and( + eq(skills.slug, normalizedSlug), + ne(skills.id, skillId), + currentSkill.workspaceId + ? eq(skills.workspaceId, currentSkill.workspaceId) + : isNull(skills.workspaceId), + ), + ) .limit(1); - if (existingSkill.length > 0) { - const wid = existingSkill[0].workspaceId; - const duplicate = await db - .select() - .from(skills) - .where( - and( - eq(skills.slug, normalizedSlug), - ne(skills.id, skillId), - wid ? eq(skills.workspaceId, wid) : isNull(skills.workspaceId) - ) - ) - .limit(1); - - if (duplicate.length > 0) { - return NextResponse.json( - { error: "A skill with this slug already exists in the same scope" }, - { status: 409 } - ); - } + if (duplicate.length > 0) { + return jsonError("A skill with this slug already exists in the same scope", 409); } updateData.slug = normalizedSlug; @@ -104,37 +85,6 @@ export async function PATCH( if (body.isEnabled !== undefined) updateData.isEnabled = body.isEnabled; // Check slug uniqueness when slug is being updated - if (body.slug !== undefined) { - const current = await db - .select() - .from(skills) - .where(eq(skills.id, skillId)) - .limit(1); - - if (current.length > 0) { - const workspaceId = current[0].workspaceId; - const existing = await db - .select() - .from(skills) - .where( - and( - eq(skills.slug, body.slug), - workspaceId - ? eq(skills.workspaceId, workspaceId) - : isNull(skills.workspaceId) - ) - ) - .limit(1); - - if (existing.length > 0 && existing[0].id !== skillId) { - return NextResponse.json( - { error: "A skill with this slug already exists in the same scope" }, - { status: 409 } - ); - } - } - } - await db .update(skills) .set(updateData) @@ -147,10 +97,7 @@ export async function PATCH( .limit(1); if (updated.length === 0) { - return NextResponse.json( - { error: "Skill not found" }, - { status: 404 } - ); + return jsonError("Skill not found", 404); } return NextResponse.json(parseSkillRow(updated[0])); @@ -160,30 +107,27 @@ export async function PATCH( error instanceof Error && error.message.includes("UNIQUE constraint failed") ) { - return NextResponse.json( - { error: "A skill with this slug already exists in the same scope" }, - { status: 409 } - ); + return jsonError("A skill with this slug already exists in the same scope", 409); } - const message = - error instanceof Error ? error.message : "Failed to update skill"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to update skill"); } } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ skillId: string }> } ) { try { const { skillId } = await params; + const access = await requireSkillAccess(request, skillId); + if (access instanceof NextResponse) { + return access; + } - await db.delete(skills).where(eq(skills.id, skillId)); + await db.delete(skills).where(eq(skills.id, access.skill.id)); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to delete skill"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to delete skill"); } } diff --git a/src/app/api/skills/claude-import/route.ts b/src/app/api/skills/claude-import/route.ts index 2e4a43ab..4cb72946 100644 --- a/src/app/api/skills/claude-import/route.ts +++ b/src/app/api/skills/claude-import/route.ts @@ -3,6 +3,8 @@ import { promises as fs } from "fs"; import path from "path"; import os from "os"; import { insertSkill, parseSkillMd } from "@/lib/db/skills-insert"; +import { requireAuth } from "@/lib/auth/server"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; /** Resolve the Claude Code config directory. * Defaults to ~/.claude but can be overridden via the request body, @@ -103,12 +105,24 @@ async function discoverClaudeSkills( // Body: { claudePath?: string, workspaceId?: string } export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json().catch(() => ({})); const { claudePath, workspaceId } = body as { claudePath?: string; workspaceId?: string | null; }; + if (workspaceId) { + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } + } + const claudeDir = resolveClaudeDir(claudePath); // Security: reject paths that aren't absolute, contain null bytes, or @@ -156,7 +170,7 @@ export async function POST(request: NextRequest) { continue; } - const id = await insertSkill(parsed, workspaceId || null); + const id = await insertSkill(parsed, workspaceId || null, auth.user.id); if (id) { imported++; importedNames.push(parsed.name); @@ -180,6 +194,11 @@ export async function POST(request: NextRequest) { // Returns discovered skill files without importing (preview) export async function GET(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const { searchParams } = new URL(request.url); const claudePath = searchParams.get("path") || undefined; const claudeDir = resolveClaudeDir(claudePath); diff --git a/src/app/api/skills/clawhub-import/route.ts b/src/app/api/skills/clawhub-import/route.ts index a6266eab..212ee60d 100644 --- a/src/app/api/skills/clawhub-import/route.ts +++ b/src/app/api/skills/clawhub-import/route.ts @@ -5,11 +5,18 @@ import { eq } from "drizzle-orm"; import { parseSkillRow } from "@/lib/db/skills-utils"; import { insertSkill, parseSkillMd } from "@/lib/db/skills-insert"; import { parseClawHubUrl } from "@/lib/utils/clawhub"; +import { requireAuth } from "@/lib/auth/server"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; const CLAWHUB_BASE = process.env.CLAWHUB_API_BASE || "https://clawhub.ai"; export async function POST(req: NextRequest) { try { + const auth = await requireAuth(req); + if (auth instanceof NextResponse) { + return auth; + } + const body = await req.json(); const { url, slug: slugOverride, workspaceId } = body as { url: string; @@ -17,6 +24,13 @@ export async function POST(req: NextRequest) { workspaceId?: string | null; }; + if (workspaceId) { + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } + } + if (!url || typeof url !== "string") { return NextResponse.json( { error: "Missing required field: url" }, @@ -106,7 +120,7 @@ export async function POST(req: NextRequest) { skillData.slug = slugOverride.trim(); } - const insertedId = await insertSkill(skillData, workspaceId || null); + const insertedId = await insertSkill(skillData, workspaceId || null, auth.user.id); if (!insertedId) { return NextResponse.json( { error: "Failed to save skill to database" }, diff --git a/src/app/api/skills/import/preview/route.ts b/src/app/api/skills/import/preview/route.ts index 7d941803..2cab2f33 100644 --- a/src/app/api/skills/import/preview/route.ts +++ b/src/app/api/skills/import/preview/route.ts @@ -7,12 +7,18 @@ import { batchProcess, type PreviewSkillItem, } from "@/lib/skills/github-fetch"; +import { requireAuth } from "@/lib/auth/server"; // POST /api/skills/import/preview // Body: { url: string } // Returns: { skills: PreviewSkillItem[], branch: string, owner: string, repo: string } export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); const { url } = body; diff --git a/src/app/api/skills/import/route.ts b/src/app/api/skills/import/route.ts index a4e3c521..d3ffddc5 100644 --- a/src/app/api/skills/import/route.ts +++ b/src/app/api/skills/import/route.ts @@ -16,6 +16,8 @@ import { batchProcess, MAX_FETCH_BYTES, } from "@/lib/skills/github-fetch"; +import { requireAuth } from "@/lib/auth/server"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; /** Check if a hostname/IP is private, loopback, or internal */ function isPrivateOrInternalHost(hostname: string): boolean { @@ -86,9 +88,21 @@ function validateSkillData(data: unknown): data is SkillExportData { // - url can be a direct JSON endpoint, or a GitHub repo/directory URL export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); const { url, skill: skillData, workspaceId, paths, branch } = body; + if (workspaceId) { + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } + } + // ─── Path A: Selective GitHub import (from preview) ─── if (url && isGitHubUrl(url) && Array.isArray(paths) && branch) { const gh = parseGitHubUrl(url); @@ -123,7 +137,7 @@ export async function POST(request: NextRequest) { return; } - const id = await insertSkill(parsed, workspaceId || null); + const id = await insertSkill(parsed, workspaceId || null, auth.user.id); if (id) { imported++; importedNames.push(parsed.name); @@ -167,7 +181,7 @@ export async function POST(request: NextRequest) { ); } - const id = await insertSkill(parsed, workspaceId || null); + const id = await insertSkill(parsed, workspaceId || null, auth.user.id); if (!id) { return NextResponse.json( { error: "Failed to create skill" }, @@ -215,7 +229,7 @@ export async function POST(request: NextRequest) { return; } - const id = await insertSkill(parsed, workspaceId || null); + const id = await insertSkill(parsed, workspaceId || null, auth.user.id); if (id) { imported++; importedNames.push(parsed.name); @@ -323,7 +337,7 @@ export async function POST(request: NextRequest) { ); } - const id = await insertSkill(importData, workspaceId || null); + const id = await insertSkill(importData, workspaceId || null, auth.user.id); if (!id) { return NextResponse.json( { error: "Failed to create skill" }, diff --git a/src/app/api/skills/route.ts b/src/app/api/skills/route.ts index 3ac0307c..2a5dc828 100644 --- a/src/app/api/skills/route.ts +++ b/src/app/api/skills/route.ts @@ -1,16 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; -import { skills, workspaces } from "@/lib/db/schema"; +import { skills } from "@/lib/db/schema"; import { eq, or, isNull, and, desc } from "drizzle-orm"; import { nanoid } from "nanoid"; import { slugify } from "@/lib/utils/slugify"; import { parseSkillRow } from "@/lib/db/skills-utils"; import { ensureProjectDefaultSkills } from "@/lib/db/default-skills"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { requireAuth } from "@/lib/auth/server"; +import { jsonError, jsonException } from "@/lib/api-errors"; // GET /api/skills?workspaceId=xxx // Returns global skills + workspace-specific skills export async function GET(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + await ensureProjectDefaultSkills(); const { searchParams } = new URL(request.url); @@ -39,15 +47,18 @@ export async function GET(request: NextRequest) { const parsed = allSkills.map(parseSkillRow); return NextResponse.json(parsed); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to list skills"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list skills"); } } // POST /api/skills export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); const { workspaceId, @@ -68,33 +79,20 @@ export async function POST(request: NextRequest) { typeof systemPrompt !== "string" || !systemPrompt.trim() ) { - return NextResponse.json( - { error: "Missing required fields: name, slug, systemPrompt" }, - { status: 400 } - ); + return jsonError("Missing required fields: name, slug, systemPrompt", 400); } const normalizedSlug = slugify(slug); if (!normalizedSlug) { - return NextResponse.json( - { error: "Invalid slug: slug must contain at least one alphanumeric character after normalization" }, - { status: 400 } - ); + return jsonError("Invalid slug: slug must contain at least one alphanumeric character after normalization", 400); } // Validate workspace exists if workspaceId is provided if (workspaceId) { - const ws = await db - .select() - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .limit(1); - if (ws.length === 0) { - return NextResponse.json( - { error: "Workspace not found" }, - { status: 404 } - ); + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; } } @@ -113,10 +111,7 @@ export async function POST(request: NextRequest) { .limit(1); if (existing.length > 0) { - return NextResponse.json( - { error: "A skill with this slug already exists in the same scope" }, - { status: 409 } - ); + return jsonError("A skill with this slug already exists in the same scope", 409); } const id = nanoid(); @@ -124,6 +119,7 @@ export async function POST(request: NextRequest) { await db.insert(skills).values({ id, + ownerUserId: auth.user.id, workspaceId: workspaceId || null, name, slug: normalizedSlug, @@ -145,8 +141,6 @@ export async function POST(request: NextRequest) { return NextResponse.json(parseSkillRow(skill[0]), { status: 201 }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to create skill"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to create skill"); } } diff --git a/src/app/api/terminal/exec/route.ts b/src/app/api/terminal/exec/route.ts index d49489cc..587a514e 100644 --- a/src/app/api/terminal/exec/route.ts +++ b/src/app/api/terminal/exec/route.ts @@ -4,6 +4,7 @@ import path from "path"; import { z } from "zod"; import { validatePath } from "@/lib/files/filesystem"; import { buildSafeExecEnv, resolveHome } from "@/lib/env"; +import { requirePathAccess } from "@/lib/auth/ownership"; const EXEC_TIMEOUT = 30_000; // 30 seconds const MAX_COMMAND_LENGTH = 4096; @@ -28,6 +29,11 @@ export async function POST(req: NextRequest) { } const { command, cwd } = parsed.data; + const access = await requirePathAccess(req, cwd); + if (access instanceof NextResponse) { + return access; + } + // Validate the working directory is within allowed workspace roots let validatedCwd: string; try { diff --git a/src/app/api/weekly-report/route.ts b/src/app/api/weekly-report/route.ts index af6de336..e9bb5ab2 100644 --- a/src/app/api/weekly-report/route.ts +++ b/src/app/api/weekly-report/route.ts @@ -3,6 +3,7 @@ import { generateWeeklyReport } from "@/lib/weekly-report"; import { db } from "@/lib/db"; import { notes } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; export async function POST(req: NextRequest) { try { @@ -14,6 +15,10 @@ export async function POST(req: NextRequest) { { status: 400 } ); } + const access = await requireWorkspaceAccess(req, workspaceId); + if (access instanceof NextResponse) { + return access; + } const result = await generateWeeklyReport( workspaceId, diff --git a/src/app/api/workspaces/[workspaceId]/datasets/route.ts b/src/app/api/workspaces/[workspaceId]/datasets/route.ts index b9f9ecf1..b7859b47 100644 --- a/src/app/api/workspaces/[workspaceId]/datasets/route.ts +++ b/src/app/api/workspaces/[workspaceId]/datasets/route.ts @@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { datasetWorkspaceLinks, hfDatasets } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonException } from "@/lib/api-errors"; type RouteParams = { params: Promise<{ workspaceId: string }> }; @@ -11,6 +13,10 @@ type RouteParams = { params: Promise<{ workspaceId: string }> }; export async function GET(_request: NextRequest, { params }: RouteParams) { try { const { workspaceId } = await params; + const access = await requireWorkspaceAccess(_request, workspaceId); + if (access instanceof NextResponse) { + return access; + } const rows = await db .select({ @@ -31,7 +37,6 @@ export async function GET(_request: NextRequest, { params }: RouteParams) { return NextResponse.json(result); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to list workspace datasets"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list workspace datasets"); } } diff --git a/src/app/api/workspaces/[workspaceId]/route.ts b/src/app/api/workspaces/[workspaceId]/route.ts index 9bca0fb1..cf25b206 100644 --- a/src/app/api/workspaces/[workspaceId]/route.ts +++ b/src/app/api/workspaces/[workspaceId]/route.ts @@ -2,26 +2,21 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; import { workspaces, sources, notes } from "@/lib/db/schema"; import { eq, count } from "drizzle-orm"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonException } from "@/lib/api-errors"; export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ workspaceId: string }> } ) { try { const { workspaceId } = await params; - const workspace = await db - .select() - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .limit(1); - - if (workspace.length === 0) { - return NextResponse.json( - { error: "Workspace not found" }, - { status: 404 } - ); + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; } + const workspace = access.workspace; // Get counts const [sourceCount] = await db @@ -41,14 +36,12 @@ export async function GET( .where(eq(workspaces.id, workspaceId)); return NextResponse.json({ - ...workspace[0], + ...workspace, sourceCount: sourceCount.count, noteCount: noteCount.count, }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to get workspace"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to get workspace"); } } @@ -58,6 +51,10 @@ export async function PATCH( ) { try { const { workspaceId } = await params; + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } const body = await request.json(); await db @@ -76,25 +73,25 @@ export async function PATCH( return NextResponse.json(updated[0]); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to update workspace"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to update workspace"); } } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ workspaceId: string }> } ) { try { const { workspaceId } = await params; + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; + } await db.delete(workspaces).where(eq(workspaces.id, workspaceId)); return NextResponse.json({ success: true }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to delete workspace"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to delete workspace"); } } diff --git a/src/app/api/workspaces/[workspaceId]/sync/route.ts b/src/app/api/workspaces/[workspaceId]/sync/route.ts index e0883232..480e30f4 100644 --- a/src/app/api/workspaces/[workspaceId]/sync/route.ts +++ b/src/app/api/workspaces/[workspaceId]/sync/route.ts @@ -1,35 +1,23 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/lib/db"; -import { workspaces } from "@/lib/db/schema"; -import { eq } from "drizzle-orm"; import { syncWorkspace } from "@/lib/rag/pipeline"; +import { requireWorkspaceAccess } from "@/lib/auth/ownership"; +import { jsonException } from "@/lib/api-errors"; export async function POST( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ workspaceId: string }> } ) { try { const { workspaceId } = await params; - - const workspace = await db - .select() - .from(workspaces) - .where(eq(workspaces.id, workspaceId)) - .limit(1); - - if (workspace.length === 0) { - return NextResponse.json( - { error: "Workspace not found" }, - { status: 404 } - ); + const access = await requireWorkspaceAccess(request, workspaceId); + if (access instanceof NextResponse) { + return access; } - const result = await syncWorkspace(workspaceId, workspace[0].folderPath); + const result = await syncWorkspace(workspaceId, access.workspace.folderPath); return NextResponse.json(result); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to sync workspace"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to sync workspace"); } } diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index cb895341..6fc9054b 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -4,32 +4,41 @@ import { workspaces } from "@/lib/db/schema"; import { desc, eq } from "drizzle-orm"; import { nanoid } from "nanoid"; import { pathExists, isDirectory, addWorkspaceRoot } from "@/lib/files/filesystem"; +import { requireAuth } from "@/lib/auth/server"; +import { ownedWorkspaceFilter } from "@/lib/auth/ownership"; +import { jsonError, jsonException } from "@/lib/api-errors"; -export async function GET() { +export async function GET(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const allWorkspaces = await db .select() .from(workspaces) + .where(ownedWorkspaceFilter(auth)) .orderBy(desc(workspaces.lastOpenedAt)); return NextResponse.json(allWorkspaces); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to list workspaces"; - return NextResponse.json({ error: message }, { status: 500 }); + return jsonException(error, "Failed to list workspaces"); } } export async function POST(request: NextRequest) { try { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + const body = await request.json(); const { name, folderPath, isGitRepo, gitRemoteUrl } = body; if (!name || !folderPath) { - return NextResponse.json( - { error: "Missing name or folderPath" }, - { status: 400 } - ); + return jsonError("Missing name or folderPath", 400); } // Register as workspace root so subsequent file-system calls are allowed @@ -37,10 +46,7 @@ export async function POST(request: NextRequest) { // Check that the folder exists if (!(await pathExists(folderPath)) || !(await isDirectory(folderPath))) { - return NextResponse.json( - { error: "Folder does not exist or is not a directory" }, - { status: 400 } - ); + return jsonError("Folder does not exist or is not a directory", 400); } // Check if a workspace already exists for this folder @@ -50,14 +56,29 @@ export async function POST(request: NextRequest) { .where(eq(workspaces.folderPath, folderPath)) .limit(1); - if (existing.length > 0) { + const ownedExisting = existing.find((workspace) => workspace.ownerUserId === auth.user.id); + if (ownedExisting) { // Reopen existing workspace await db .update(workspaces) .set({ lastOpenedAt: new Date().toISOString() }) - .where(eq(workspaces.id, existing[0].id)); + .where(eq(workspaces.id, ownedExisting.id)); + + return NextResponse.json(ownedExisting); + } - return NextResponse.json(existing[0]); + if (existing.length > 0) { + const existingOwner = existing[0].ownerUserId; + if (existingOwner && existingOwner !== auth.user.id) { + return jsonError("This folder is already registered to another account", 409); + } + if (!existingOwner && auth.user.role === "admin") { + await db + .update(workspaces) + .set({ ownerUserId: auth.user.id, lastOpenedAt: new Date().toISOString() }) + .where(eq(workspaces.id, existing[0].id)); + return NextResponse.json(existing[0]); + } } const id = nanoid(); @@ -65,6 +86,7 @@ export async function POST(request: NextRequest) { await db.insert(workspaces).values({ id, + ownerUserId: auth.user.id, name, folderPath, isGitRepo: isGitRepo || false, @@ -82,9 +104,6 @@ export async function POST(request: NextRequest) { return NextResponse.json(workspace[0], { status: 201 }); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to create workspace"; - const status = message.includes("Access denied") ? 403 : 500; - return NextResponse.json({ error: message }, { status }); + return jsonException(error, "Failed to create workspace"); } } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 00000000..452987d1 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Bot, LogIn } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + setLoading(true); + setError(""); + + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data = await res.json().catch(() => ({})); + setLoading(false); + + if (!res.ok) { + setError(data.error || "Login failed"); + return; + } + + const next = new URLSearchParams(window.location.search).get("next") || "/"; + router.replace(next); + router.refresh(); + } + + return ( + <main className="flex min-h-screen items-center justify-center bg-background px-4"> + <Card className="w-full max-w-md border-border/70 shadow-lg"> + <CardHeader className="space-y-3"> + <div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10 text-primary"> + <Bot className="h-5 w-5" /> + </div> + <div> + <CardTitle className="text-2xl">Sign in to InnoClaw</CardTitle> + <CardDescription>Use your local account to continue.</CardDescription> + </div> + </CardHeader> + <CardContent> + <form className="space-y-4" onSubmit={handleSubmit}> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + autoComplete="current-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + /> + </div> + {error && <p className="text-sm text-destructive">{error}</p>} + <Button className="w-full gap-2" type="submit" disabled={loading}> + <LogIn className="h-4 w-4" /> + {loading ? "Signing in..." : "Sign in"} + </Button> + </form> + <p className="mt-5 text-center text-sm text-muted-foreground"> + No account yet?{" "} + <Link className="font-medium text-primary hover:underline" href="/register"> + Create one + </Link> + </p> + </CardContent> + </Card> + </main> + ); +} diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 00000000..69f086f1 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { UserPlus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +export default function RegisterPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(event: FormEvent<HTMLFormElement>) { + event.preventDefault(); + setLoading(true); + setError(""); + + const res = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name, password }), + }); + + const data = await res.json().catch(() => ({})); + setLoading(false); + + if (!res.ok) { + setError(data.error || "Registration failed"); + return; + } + + router.replace(data.requiresSetup ? "/settings" : "/"); + router.refresh(); + } + + return ( + <main className="flex min-h-screen items-center justify-center bg-background px-4"> + <Card className="w-full max-w-md border-border/70 shadow-lg"> + <CardHeader className="space-y-3"> + <div className="flex h-11 w-11 items-center justify-center rounded-lg bg-primary/10 text-primary"> + <UserPlus className="h-5 w-5" /> + </div> + <div> + <CardTitle className="text-2xl">Create account</CardTitle> + <CardDescription>Open registration is enabled on this system.</CardDescription> + </div> + </CardHeader> + <CardContent> + <form className="space-y-4" onSubmit={handleSubmit}> + <div className="space-y-2"> + <Label htmlFor="name">Name</Label> + <Input + id="name" + value={name} + onChange={(event) => setName(event.target.value)} + autoComplete="name" + /> + </div> + <div className="space-y-2"> + <Label htmlFor="email">Email</Label> + <Input + id="email" + type="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + autoComplete="email" + required + /> + </div> + <div className="space-y-2"> + <Label htmlFor="password">Password</Label> + <Input + id="password" + type="password" + value={password} + onChange={(event) => setPassword(event.target.value)} + autoComplete="new-password" + minLength={8} + required + /> + </div> + {error && <p className="text-sm text-destructive">{error}</p>} + <Button className="w-full gap-2" type="submit" disabled={loading}> + <UserPlus className="h-4 w-4" /> + {loading ? "Creating..." : "Create account"} + </Button> + </form> + <p className="mt-5 text-center text-sm text-muted-foreground"> + Already have an account?{" "} + <Link className="font-medium text-primary hover:underline" href="/login"> + Sign in + </Link> + </p> + </CardContent> + </Card> + </main> + ); +} diff --git a/src/components/agent/agent-panel.tsx b/src/components/agent/agent-panel.tsx index 3cb1b65e..0da123e4 100644 --- a/src/components/agent/agent-panel.tsx +++ b/src/components/agent/agent-panel.tsx @@ -32,28 +32,16 @@ import { getOverflowThresholdChars, getMessageTextLength, getContextWindowChars, import type { ProviderId } from "@/lib/ai/models"; import { SkillAutocomplete } from "@/components/skills/skill-autocomplete"; import { SkillParameterDialog } from "@/components/skills/skill-parameter-dialog"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; -import { Textarea } from "@/components/ui/textarea"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { ParticleEffect, ThinkingIndicator, FloatingOrbs } from "@/components/ui/particle-effect"; import type { Skill } from "@/types"; import { swrFetcher as fetcher } from "@/lib/fetcher"; -import { - resolveModelSelection, - type ModelCatalogEntry, - type ProviderModelCatalog, -} from "@/lib/ai/model-selection"; +import { useModelSelection } from "@/lib/hooks/use-model-selection"; +import { useCostTracking } from "@/lib/hooks/use-cost-tracking"; import { AgentMessage } from "./agent-message"; +import { CostDisplay } from "./cost-display"; +import { MemoryPanel } from "./memory-panel"; +import { BuddyAvatar } from "./buddy-avatar"; +import { BuddyHatchDialog } from "./buddy-hatch-dialog"; import { WorkspaceImagePickerDialog } from "./workspace-image-picker-dialog"; import { toast } from "sonner"; import { @@ -63,17 +51,30 @@ import { } from "@/lib/ai/message-attachments"; import { ImageAttachmentGrid } from "@/components/ui/image-attachment-grid"; import { + BUILTIN_COMMANDS, getMatchingSkillsForSlashQuery, shouldAutocompleteCaptureEnter, + type BuiltinCommand, } from "./slash-command"; +import { extractMemoryTags } from "@/lib/agent/kairos-memory"; +import { + buildOverflowCompactionPlan, + excludeKeptMessages, + getRenderableMessages, + requestConversationSummaryPreview, + saveConversationMemoryNote, +} from "@/lib/agent/conversation-compaction"; +import { getMessageText } from "./message-utils"; +import { useDraggableDialog, ResizeHandles } from "./use-draggable-dialog"; import { getWorkspaceImageMimeType } from "./workspace-image-picker-utils"; import { focusAgentInputAfterDialogClose } from "./workspace-image-picker-utils"; +import { + ConversationMemoryPreviewDialog, + ConversationMessageSelectionDialog, +} from "@/components/conversation/conversation-compaction-dialogs"; type AgentMode = "long-agent" | "agent" | "plan" | "ask"; type ModelSelection = { provider: string; model: string }; -type AgentModelOptionsKey = readonly ["agent-model-options", ...ProviderId[]]; -type ModelOption = ModelCatalogEntry; -type ProviderOption = ProviderModelCatalog; /** Pixel threshold for considering the user "at the bottom" of the scroll area */ const BOTTOM_THRESHOLD_PX = 80; @@ -108,30 +109,6 @@ const MODE_PLACEHOLDER_KEYS: Record<AgentMode, "placeholder" | "placeholderLongA ask: "placeholderAsk", }; -function readStoredModelSelection(storageKey: string): ModelSelection | null { - try { - if (typeof window === "undefined" || !window.localStorage) { - return null; - } - const stored = window.localStorage.getItem(storageKey); - if (!stored) { - return null; - } - const parsed = JSON.parse(stored); - if ( - typeof parsed?.provider === "string" && - parsed.provider && - typeof parsed?.model === "string" && - parsed.model - ) { - return { provider: parsed.provider, model: parsed.model }; - } - } catch { - // Ignore storage access and parse errors. - } - return null; -} - // --- Main Panel --- interface AgentPanelProps { @@ -161,11 +138,6 @@ export function AgentPanel({ const [input, setInput] = useState(""); const [pendingImages, setPendingImages] = useState<FileUIPart[]>([]); const [mode, setMode] = useState<AgentMode>("agent"); - const [userSelection, setUserSelection] = useState<ModelSelection | null>( - () => readStoredModelSelection("innoclaw-agent-model-selection") - ); - - const MODEL_SELECTION_STORAGE_KEY = "innoclaw-agent-model-selection"; // Draggable input area height const [inputHeight, setInputHeight] = useState(80); @@ -205,6 +177,14 @@ export function AgentPanel({ const summarizingRef = useRef(false); const failedAtCountRef = useRef(-1); + // Memory panel state + const [showMemoryPanel, setShowMemoryPanel] = useState(false); + const [showCostDisplay, setShowCostDisplay] = useState(true); + + // Buddy companion state + const [showBuddyHatch, setShowBuddyHatch] = useState(false); + const [buddyKey, setBuddyKey] = useState(0); + // Memory preview dialog state const [showMessageSelect, setShowMessageSelect] = useState(false); const [selectedMessageIds, setSelectedMessageIds] = useState<Set<string>>(new Set()); @@ -222,84 +202,10 @@ export function AgentPanel({ // Track overflow-triggered dialog: stores messages to keep after memory save const overflowKeepRef = useRef<UIMessage[] | null>(null); - // Draggable + resizable dialog state - const [dialogPos, setDialogPos] = useState({ x: 0, y: 0 }); - const [dialogSize, setDialogSize] = useState({ width: 512, height: 520 }); - const [isDragging, setIsDragging] = useState(false); - const [resizeEdge, setResizeEdge] = useState<string | null>(null); - const dragStartRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 }); - const resizeStartRef = useRef({ mouseX: 0, mouseY: 0, w: 0, h: 0, posX: 0, posY: 0 }); - - // Reset dialog position/size when opened - useEffect(() => { - if (showMemoryPreview) { - setDialogPos({ x: 0, y: 0 }); - setDialogSize({ width: 512, height: 520 }); - } - }, [showMemoryPreview]); - - // Global pointer listeners for dragging - useEffect(() => { - if (!isDragging) return; - const onMove = (e: PointerEvent) => { - const s = dragStartRef.current; - setDialogPos({ x: s.posX + e.clientX - s.mouseX, y: s.posY + e.clientY - s.mouseY }); - }; - const onUp = () => setIsDragging(false); - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - return () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - }; - }, [isDragging]); - - // Global pointer listeners for resizing (all edges/corners) - useEffect(() => { - if (!resizeEdge) return; - const onMove = (e: PointerEvent) => { - const s = resizeStartRef.current; - const dx = e.clientX - s.mouseX; - const dy = e.clientY - s.mouseY; - let newW = s.w, newH = s.h, newX = s.posX, newY = s.posY; - - if (resizeEdge.includes("e")) newW = s.w + dx; - if (resizeEdge.includes("w")) { newW = s.w - dx; newX = s.posX + dx; } - if (resizeEdge.includes("s")) newH = s.h + dy; - if (resizeEdge.includes("n")) { newH = s.h - dy; newY = s.posY + dy; } - - // Enforce minimums and clamp position - if (newW < 360) { newW = 360; if (resizeEdge.includes("w")) newX = s.posX + s.w - 360; } - if (newH < 300) { newH = 300; if (resizeEdge.includes("n")) newY = s.posY + s.h - 300; } - - setDialogSize({ width: newW, height: newH }); - setDialogPos({ x: newX, y: newY }); - }; - const onUp = () => setResizeEdge(null); - window.addEventListener("pointermove", onMove); - window.addEventListener("pointerup", onUp); - return () => { - window.removeEventListener("pointermove", onMove); - window.removeEventListener("pointerup", onUp); - }; - }, [resizeEdge]); - - const onDragStart = useCallback((e: React.PointerEvent) => { - // Don't drag when clicking close button or inputs - if ((e.target as HTMLElement).closest("button")) return; - dragStartRef.current = { mouseX: e.clientX, mouseY: e.clientY, posX: dialogPos.x, posY: dialogPos.y }; - setIsDragging(true); - }, [dialogPos]); - - const onEdgeResizeStart = useCallback((e: React.PointerEvent, edge: string) => { - e.stopPropagation(); - resizeStartRef.current = { - mouseX: e.clientX, mouseY: e.clientY, - w: dialogSize.width, h: dialogSize.height, - posX: dialogPos.x, posY: dialogPos.y, - }; - setResizeEdge(edge); - }, [dialogSize, dialogPos]); + // Shared draggable + resizable dialog (message-select and memory-preview are mutually exclusive) + const { dialogStyle, onDragStart, onEdgeResizeStart } = useDraggableDialog({ + open: showMessageSelect || showMemoryPreview, + }); const { data: settings } = useSWR("/api/settings", fetcher); const aiEnabled = settings?.hasAIKey ?? false; @@ -310,54 +216,6 @@ export function AgentPanel({ return configured.filter((id): id is ProviderId => Boolean(PROVIDERS[id as ProviderId])); }, [settings?.configuredProviders]); - const modelOptionsKey = useMemo<AgentModelOptionsKey | null>(() => { - if (configuredProviderIds.length === 0) { - return null; - } - return ["agent-model-options", ...configuredProviderIds]; - }, [configuredProviderIds]); - - const { data: discoveredModelsByProvider, mutate: refreshDiscoveredModels } = useSWR< - Record<string, ModelOption[]> - >( - modelOptionsKey, - async (key: AgentModelOptionsKey) => { - const [, ...providerIds] = key; - const entries = await Promise.all( - providerIds.map(async (providerId) => { - try { - const response = await fetch(`/api/models?provider=${encodeURIComponent(providerId)}`); - const data = await response.json().catch(() => ({})); - return [providerId, Array.isArray(data.models) ? data.models : []] as const; - } catch { - return [providerId, []] as const; - } - }), - ); - return Object.fromEntries(entries); - }, - ); - - const availableProviders = useMemo<ProviderOption[]>(() => { - const providers: ProviderOption[] = []; - for (const id of configuredProviderIds) { - const provider = PROVIDERS[id]; - if (!provider) continue; - - const knownIds = new Set(provider.models.map((model) => model.id)); - const extraModels = (discoveredModelsByProvider?.[id] ?? []).filter( - (model) => !knownIds.has(model.id), - ); - - providers.push({ - id: provider.id, - name: provider.name, - models: [...provider.models, ...extraModels], - }); - } - return providers; - }, [configuredProviderIds, discoveredModelsByProvider]); - const settingsFallback = useMemo<ModelSelection | null>(() => { if (!settings?.llmProvider || !settings?.llmModel) return null; if ( @@ -366,98 +224,24 @@ export function AgentPanel({ ) { return null; } - return { - provider: settings.llmProvider as string, - model: settings.llmModel as string, - }; + return { provider: settings.llmProvider as string, model: settings.llmModel as string }; }, [configuredProviderIds, settings?.llmModel, settings?.llmProvider]); - const resolvedSelection = useMemo(() => { - const selection = userSelection ?? settingsFallback; - const unmatchedKind = - selection && (discoveredModelsByProvider?.[selection.provider]?.length ?? 0) > 0 - ? "not-found" - : "custom"; - return resolveModelSelection(selection, availableProviders, { unmatchedKind }); - }, [availableProviders, discoveredModelsByProvider, settingsFallback, userSelection]); - - const canonicalSelection = useMemo<ModelSelection | null>(() => ( - resolvedSelection - ? { provider: resolvedSelection.provider, model: resolvedSelection.resolvedModel } - : null - ), [resolvedSelection]); - - useEffect(() => { - if (!userSelection) return; - - const providerStillConfigured = - configuredProviderIds.length === 0 || - configuredProviderIds.includes(userSelection.provider as ProviderId); - const providerExists = Boolean(PROVIDERS[userSelection.provider as ProviderId]); - - if (providerExists && providerStillConfigured) { - return; - } - - setUserSelection(null); - try { - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.removeItem(MODEL_SELECTION_STORAGE_KEY); - } - } catch { - // Ignore storage access errors. - } - }, [configuredProviderIds, userSelection]); - - useEffect(() => { - if (!userSelection || !canonicalSelection) return; - if ( - canonicalSelection.provider === userSelection.provider && - canonicalSelection.model === userSelection.model - ) { - return; - } - - setUserSelection(canonicalSelection); - try { - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.setItem( - MODEL_SELECTION_STORAGE_KEY, - JSON.stringify(canonicalSelection), - ); - } - } catch { - // Ignore storage access errors. - } - }, [MODEL_SELECTION_STORAGE_KEY, canonicalSelection, userSelection]); - - const selectedProvider = canonicalSelection?.provider ?? null; - const selectedModel = canonicalSelection?.model ?? null; - - const handleModelChange = useCallback((providerId: string, modelId: string) => { - setUserSelection({ provider: providerId, model: modelId }); - try { - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.setItem( - MODEL_SELECTION_STORAGE_KEY, - JSON.stringify({ provider: providerId, model: modelId }) - ); - } - } catch { - // Ignore storage access errors; state has already been updated - } - }, []); - - const modelDisplayName = useMemo(() => { - return resolvedSelection?.displayName ?? t("modelLabel"); - }, [resolvedSelection, t]); - - const selectedSupportsVision = useMemo(() => { - if (!selectedProvider || !selectedModel || resolvedSelection?.matchKind === "unmatched") { - return null; - } - return modelSupportsVision(selectedProvider, selectedModel); - }, [resolvedSelection?.matchKind, selectedProvider, selectedModel]); + const { + selectedProvider, + selectedModel, + modelDisplayName, + resolvedSelection, + availableProviders, + selectedSupportsVision, + handleModelChange, + refreshDiscoveredModels, + } = useModelSelection({ + storageKey: "innoclaw-agent-model-selection", + configuredProviderIds, + settingsFallback, + fallbackDisplayName: t("modelLabel"), + }); // Mutable body object — allows injecting skillId/paramValues before each send const agentBody = useMemo( @@ -808,6 +592,49 @@ export function AgentPanel({ [totalMessageChars, contextWindowChars] ); + const { costSnapshot } = useCostTracking({ + storageKey: `agent-cost:${workspaceId}:${sessionId}`, + messages, + status, + resolvedModel, + }); + + // Extract <memory> tags from assistant messages and save to daily log + const lastMemoryExtractRef = useRef(0); + useEffect(() => { + if (status !== "ready") return; + if (messages.length <= lastMemoryExtractRef.current) { + lastMemoryExtractRef.current = messages.length; + return; + } + const newMessages = messages.slice(lastMemoryExtractRef.current); + lastMemoryExtractRef.current = messages.length; + + for (const msg of newMessages) { + if (msg.role !== "assistant") continue; + const text = getMessageText(msg); + const memories = extractMemoryTags(text); + for (const mem of memories) { + fetch("/api/agent/memory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId, action: "remember", text: mem }), + }).catch(() => { /* best-effort */ }); + } + } + }, [messages, status, workspaceId]); + + // Last assistant message text for buddy reactions + const lastAssistantMessage = useMemo(() => { + if (status === "streaming" || status === "submitted") return undefined; + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "assistant") { + return getMessageText(messages[i]) || undefined; + } + } + return undefined; + }, [messages, status]); + const summarizeAndEvict = async ( messagesToSummarize: UIMessage[], messagesToKeep: UIMessage[], @@ -888,34 +715,13 @@ export function AgentPanel({ if (restoreGenRef.current > 0 || isSummarizing) return; if (showMessageSelect || showMemoryPreview) return; // Don't trigger while dialog is open if (status !== "ready" && status !== "error") return; - if (messages.length < 4) return; - if (messages.length === failedAtCountRef.current) return; - - // Reuse pre-computed totalMessageChars for the early-exit check - if (totalMessageChars <= overflowThreshold) return; - - // Need per-message sizes only for split-point calculation - const messageSizes = messages.map((m) => getMessageTextLength(m)); - - // Find split point: keep newest ~20% by character count - let keepFromIndex = messages.length; - let accumulatedChars = 0; - const targetKeepChars = totalMessageChars * 0.2; - - for (let i = messages.length - 1; i >= 0; i--) { - accumulatedChars += messageSizes[i]; - if (accumulatedChars >= targetKeepChars) { - keepFromIndex = i; - break; - } - } - - // Keep at least the last 2 messages - keepFromIndex = Math.min(keepFromIndex, messages.length - 2); - if (keepFromIndex <= 0) return; - - const toSummarize = messages.slice(0, keepFromIndex); - const toKeep = messages.slice(keepFromIndex); + const plan = buildOverflowCompactionPlan({ + messages, + overflowThreshold, + failedAtCount: failedAtCountRef.current, + }); + if (!plan) return; + const { toSummarize, toKeep } = plan; // In long-agent mode, auto-summarize without user interaction to keep the pipeline flowing if (mode === "long-agent") { @@ -926,9 +732,7 @@ export function AgentPanel({ // Show message selection dialog instead of auto-summarizing overflowKeepRef.current = toKeep; // Only pre-select messages with renderable text content - setSelectedMessageIds(new Set( - toSummarize.filter((m) => getMessageTextLength(m) > 0).map((m) => m.id) - )); + setSelectedMessageIds(new Set(getRenderableMessages(toSummarize).map((message) => message.id))); setShowMessageSelect(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages, status, isSummarizing, showMessageSelect, showMemoryPreview]); @@ -1006,6 +810,54 @@ export function AgentPanel({ } }; + const handleBuiltinCommand = (command: BuiltinCommand, args?: string) => { + setShowAutocomplete(false); + setInput(""); + switch (command.slug) { + case "compact": + if (messages.length >= 4) { + const keepCount = Math.max(2, Math.floor(messages.length * 0.3)); + const toSummarize = messages.slice(0, messages.length - keepCount); + const toKeep = messages.slice(messages.length - keepCount); + summarizeAndEvict(toSummarize, toKeep, "overflow"); + } else { + toast.info("Not enough messages to compact"); + } + break; + case "cost": + setShowCostDisplay((prev) => !prev); + break; + case "memory": + setShowMemoryPanel(true); + break; + case "remember": + if (args) { + fetch("/api/agent/memory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId, action: "remember", text: args }), + }).then((res) => { + if (res.ok) toast.success("Memory saved"); + else toast.error("Failed to save memory"); + }).catch(() => toast.error("Failed to save memory")); + } else { + setInput("/remember "); + } + break; + case "dream": + toast.info("Starting dream consolidation..."); + fetch("/api/agent/memory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId, action: "dream" }), + }).then((res) => { + if (res.ok) toast.success("Dream consolidation complete"); + else toast.error("Dream consolidation failed"); + }).catch(() => toast.error("Dream consolidation failed")); + break; + } + }; + // Execute skill after params are collected const executeSkill = async ( skill: Skill, @@ -1055,6 +907,18 @@ export function AgentPanel({ } userScrolledUp.current = false; + // Handle built-in slash commands before skill matching + if (text.startsWith("/")) { + const parts = text.slice(1).split(/\s+/); + const cmd = parts[0].toLowerCase(); + const args = parts.slice(1).join(" "); + const builtinMatch = BUILTIN_COMMANDS.find((c) => c.slug === cmd); + if (builtinMatch) { + handleBuiltinCommand(builtinMatch, args); + return; + } + } + // Check if input matches a skill slug if (text.startsWith("/")) { const query = text.slice(1).toLowerCase(); @@ -1164,16 +1028,9 @@ export function AgentPanel({ }, 100); }; - // Helper: extract plain text from a message - const getMessageText = (message: UIMessage) => - message.parts - ?.filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") ?? ""; - // Messages that have renderable text content (used for selection UI) const selectableMessages = useMemo( - () => messages.filter((m) => getMessageTextLength(m) > 0), + () => getRenderableMessages(messages), [messages] ); @@ -1201,24 +1058,14 @@ export function AgentPanel({ setIsSummarizing(true); setSummaryError(null); try { - const res = await fetch("/api/agent/summarize", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workspaceId, - messages: selected, - trigger: overflowKeepRef.current ? "overflow" : "clear", - preview: true, - compact: true, - locale, - sessionName, - }), + const data = await requestConversationSummaryPreview({ + workspaceId, + messages: selected, + trigger: overflowKeepRef.current ? "overflow" : "clear", + compact: true, + locale, + sessionName, }); - if (!res.ok) { - const errData = await res.json().catch(() => ({ error: "Summarization failed" })); - throw new Error(errData.error || "Summarization failed"); - } - const data = await res.json(); setMemoryPreviewTitle(data.title); setMemoryPreviewContent(data.content); setShowMemoryPreview(true); @@ -1235,9 +1082,7 @@ export function AgentPanel({ if (overflowKeepRef.current) { // User cancelled during overflow — fall back to silent auto-summarize const toKeep = overflowKeepRef.current; - const toSummarize = messages.filter( - (m) => !toKeep.some((k) => k.id === m.id) - ); + const toSummarize = excludeKeptMessages(messages, toKeep); overflowKeepRef.current = null; summarizeAndEvict(toSummarize, toKeep, "overflow"); } @@ -1247,22 +1092,11 @@ export function AgentPanel({ setShowMemoryPreview(false); setIsSummarizing(true); try { - const res = await fetch("/api/notes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workspaceId, - title: memoryPreviewTitle, - content: memoryPreviewContent, - type: "memory", - }), + await saveConversationMemoryNote({ + workspaceId, + title: memoryPreviewTitle, + content: memoryPreviewContent, }); - if (!res.ok) { - const errData = await res.json().catch(() => ({})); - const errorMessage = - errData && typeof errData.error === "string" ? errData.error : t("memoryError"); - throw new Error(errorMessage); - } if (overflowKeepRef.current) { // Overflow: keep recent messages, inject compact summary as context const contextSummary = makeContextSummaryMessage(memoryPreviewContent, t("contextCompactedNotice")); @@ -1286,9 +1120,7 @@ export function AgentPanel({ if (overflowKeepRef.current) { // User cancelled memory preview during overflow — fall back to silent auto-summarize const toKeep = overflowKeepRef.current; - const toSummarize = messages.filter( - (m) => !toKeep.some((k) => k.id === m.id) - ); + const toSummarize = excludeKeptMessages(messages, toKeep); overflowKeepRef.current = null; summarizeAndEvict(toSummarize, toKeep, "overflow"); } @@ -1347,11 +1179,7 @@ export function AgentPanel({ messages.map((message) => { // Filter out auto-continue messages (user messages with only "Continue" / "继续") if (message.role === "user") { - const text = message.parts - ?.filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") - .trim(); + const text = getMessageText(message).trim(); if (text === "Continue" || text === "继续") return null; } return <AgentMessage key={message.id} message={message} />; @@ -1425,6 +1253,7 @@ export function AgentPanel({ query={slashQuery} skills={availableSkills} onSelect={handleSkillSelect} + onBuiltinSelect={handleBuiltinCommand} onClose={() => setShowAutocomplete(false)} /> )} @@ -1581,6 +1410,15 @@ export function AgentPanel({ autoFocus /> <div className="flex items-center gap-1 shrink-0 mt-1"> + {/* Buddy avatar */} + <BuddyAvatar + key={buddyKey} + workspaceId={workspaceId} + lastAssistantMessage={lastAssistantMessage} + onHatchRequest={() => setShowBuddyHatch(true)} + /> + {/* Cost display */} + {showCostDisplay && <CostDisplay snapshot={costSnapshot} />} {/* Context usage percentage */} {messages.length > 0 && ( <span @@ -1628,6 +1466,20 @@ export function AgentPanel({ } /> + {/* KAIROS Memory panel */} + <MemoryPanel + open={showMemoryPanel} + onOpenChange={setShowMemoryPanel} + workspaceId={workspaceId} + /> + + {/* Buddy hatch dialog */} + <BuddyHatchDialog + open={showBuddyHatch} + onOpenChange={setShowBuddyHatch} + onHatched={() => setBuddyKey((k) => k + 1)} + /> + {/* Skill parameter dialog */} {activeSkill && ( <SkillParameterDialog @@ -1641,192 +1493,70 @@ export function AgentPanel({ /> )} - {/* Message selection dialog */} - <Dialog open={showMessageSelect} onOpenChange={(open) => { - if (!open) handleSelectCancel(); - }}> - <DialogContent - className="flex flex-col !p-0 overflow-hidden" - style={{ - width: dialogSize.width, - height: dialogSize.height, - maxWidth: "none", - maxHeight: "none", - transform: `translate(calc(-50% + ${dialogPos.x}px), calc(-50% + ${dialogPos.y}px))`, - }} - > - <DialogHeader - className="cursor-move select-none px-6 pt-6 pb-2" - onPointerDown={onDragStart} - > - <DialogTitle>{t("selectMessagesTitle")}</DialogTitle> - <DialogDescription>{t("selectMessagesDesc")}</DialogDescription> - </DialogHeader> - - <div className="flex items-center gap-3 px-6 pb-2"> - <Button - variant="outline" - size="sm" - onClick={() => setSelectedMessageIds(new Set(selectableMessages.map((m) => m.id)))} - > - {t("selectAll")} - </Button> - <Button - variant="outline" - size="sm" - onClick={() => setSelectedMessageIds(new Set())} - > - {t("selectNone")} - </Button> - <span className="text-xs text-muted-foreground ml-auto"> - {selectedMessageIds.size} / {selectableMessages.length} - </span> - </div> + <ConversationMessageSelectionDialog + open={showMessageSelect} + onCancel={handleSelectCancel} + messages={messages} + selectedMessageIds={selectedMessageIds} + getMessageText={getMessageText} + onToggleMessage={toggleMessage} + onSelectAll={() => setSelectedMessageIds(new Set(selectableMessages.map((m) => m.id)))} + onSelectNone={() => setSelectedMessageIds(new Set())} + onConfirm={handleSelectNext} + onClearAll={() => { + setShowMessageSelect(false); + setSelectedMessageIds(new Set()); + setMessages([]); + }} + labels={{ + title: t("selectMessagesTitle"), + description: t("selectMessagesDesc"), + roleUser: t("roleUser"), + roleAssistant: t("roleAssistant"), + selectAll: t("selectAll"), + selectNone: t("selectNone"), + cancel: tCommon("cancel"), + clearAll: t("clearAll"), + confirm: t("nextStep"), + }} + selectedCount={selectedMessageIds.size} + totalCount={selectableMessages.length} + showCount + variant="terminal" + className="flex flex-col !p-0 overflow-hidden" + style={dialogStyle} + headerClassName="cursor-move select-none px-6 pt-6 pb-2" + footerClassName="px-6 pb-6 pt-2" + scrollAreaClassName="flex-1 min-h-0 px-6" + onHeaderPointerDown={onDragStart} + footerExtra={<ResizeHandles onEdgeResizeStart={onEdgeResizeStart} />} + /> - <ScrollArea className="flex-1 min-h-0 px-6"> - <div className="space-y-2 py-2 pr-4" role="listbox" aria-multiselectable="true"> - {messages.map((msg) => { - const text = getMessageText(msg); - if (!text) return null; - const checked = selectedMessageIds.has(msg.id); - return ( - <div - key={msg.id} - role="option" - aria-selected={checked} - tabIndex={0} - className={`flex items-start gap-3 rounded-md border px-3 py-2 cursor-pointer transition-colors ${ - checked - ? "border-[#7aa2f7]/50 bg-[#7aa2f7]/5" - : "border-[#30363d] hover:border-[#484f58]" - }`} - onClick={() => toggleMessage(msg.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleMessage(msg.id); - } - }} - > - <Checkbox - checked={checked} - onCheckedChange={() => toggleMessage(msg.id)} - onClick={(e: React.MouseEvent) => e.stopPropagation()} - className="mt-0.5 shrink-0" - /> - <div className="min-w-0 flex-1"> - <span className={`text-xs font-medium ${ - msg.role === "user" ? "text-[#bb9af7]" : "text-[#7aa2f7]" - }`}> - {msg.role === "user" ? t("roleUser") : t("roleAssistant")} - </span> - <p className="text-xs text-[#c9d1d9] line-clamp-3 mt-0.5 whitespace-pre-wrap"> - {text} - </p> - </div> - </div> - ); - })} - </div> - </ScrollArea> - - <DialogFooter className="px-6 pb-6 pt-2"> - <Button variant="outline" onClick={handleSelectCancel}> - {tCommon("cancel")} - </Button> - <Button - variant="destructive" - onClick={() => { - setShowMessageSelect(false); - setSelectedMessageIds(new Set()); - setMessages([]); - }} - > - {t("clearAll")} - </Button> - <Button onClick={handleSelectNext} disabled={selectedMessageIds.size === 0}> - {t("nextStep")} - </Button> - </DialogFooter> - - {/* Edge resize handles */} - <div className="absolute top-0 left-3 right-3 h-1.5 cursor-n-resize" onPointerDown={(e) => onEdgeResizeStart(e, "n")} /> - <div className="absolute bottom-0 left-3 right-3 h-1.5 cursor-s-resize" onPointerDown={(e) => onEdgeResizeStart(e, "s")} /> - <div className="absolute left-0 top-3 bottom-3 w-1.5 cursor-w-resize" onPointerDown={(e) => onEdgeResizeStart(e, "w")} /> - <div className="absolute right-0 top-3 bottom-3 w-1.5 cursor-e-resize" onPointerDown={(e) => onEdgeResizeStart(e, "e")} /> - {/* Corner resize handles */} - <div className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize" onPointerDown={(e) => onEdgeResizeStart(e, "nw")} /> - <div className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize" onPointerDown={(e) => onEdgeResizeStart(e, "ne")} /> - <div className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize" onPointerDown={(e) => onEdgeResizeStart(e, "sw")} /> - <div className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize" onPointerDown={(e) => onEdgeResizeStart(e, "se")} /> - </DialogContent> - </Dialog> - - {/* Memory preview dialog */} - <Dialog open={showMemoryPreview} onOpenChange={(open) => { - if (!open) handleMemoryCancel(); - }}> - <DialogContent - className="flex flex-col !p-0 overflow-hidden" - style={{ - width: dialogSize.width, - height: dialogSize.height, - maxWidth: "none", - maxHeight: "none", - transform: `translate(calc(-50% + ${dialogPos.x}px), calc(-50% + ${dialogPos.y}px))`, - }} - > - {/* Drag handle */} - <DialogHeader - className="cursor-move select-none px-6 pt-6 pb-2" - onPointerDown={onDragStart} - > - <DialogTitle>{t("memoryPreviewTitle")}</DialogTitle> - <DialogDescription>{t("memoryPreviewDesc")}</DialogDescription> - </DialogHeader> - - <ScrollArea className="flex-1 min-h-0 px-6"> - <div className="space-y-4 py-2 pr-4"> - <div className="space-y-1.5"> - <Label>{t("memoryNoteTitle")}</Label> - <Input - value={memoryPreviewTitle} - onChange={(e) => setMemoryPreviewTitle(e.target.value)} - /> - </div> - <div className="space-y-1.5"> - <Label>{t("memoryNoteContent")}</Label> - <Textarea - value={memoryPreviewContent} - onChange={(e) => setMemoryPreviewContent(e.target.value)} - rows={12} - className="font-mono text-xs" - /> - </div> - </div> - </ScrollArea> - - <DialogFooter className="px-6 pb-6 pt-2"> - <Button variant="outline" onClick={handleMemoryCancel}> - {tCommon("cancel")} - </Button> - <Button onClick={handleMemoryConfirm} disabled={!memoryPreviewTitle.trim()}> - {tCommon("confirm")} - </Button> - </DialogFooter> - - {/* Edge resize handles */} - <div className="absolute top-0 left-3 right-3 h-1.5 cursor-n-resize" onPointerDown={(e) => onEdgeResizeStart(e, "n")} /> - <div className="absolute bottom-0 left-3 right-3 h-1.5 cursor-s-resize" onPointerDown={(e) => onEdgeResizeStart(e, "s")} /> - <div className="absolute left-0 top-3 bottom-3 w-1.5 cursor-w-resize" onPointerDown={(e) => onEdgeResizeStart(e, "w")} /> - <div className="absolute right-0 top-3 bottom-3 w-1.5 cursor-e-resize" onPointerDown={(e) => onEdgeResizeStart(e, "e")} /> - {/* Corner resize handles */} - <div className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize" onPointerDown={(e) => onEdgeResizeStart(e, "nw")} /> - <div className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize" onPointerDown={(e) => onEdgeResizeStart(e, "ne")} /> - <div className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize" onPointerDown={(e) => onEdgeResizeStart(e, "sw")} /> - <div className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize" onPointerDown={(e) => onEdgeResizeStart(e, "se")} /> - </DialogContent> - </Dialog> + <ConversationMemoryPreviewDialog + open={showMemoryPreview} + onCancel={handleMemoryCancel} + titleValue={memoryPreviewTitle} + contentValue={memoryPreviewContent} + onTitleChange={setMemoryPreviewTitle} + onContentChange={setMemoryPreviewContent} + onConfirm={handleMemoryConfirm} + confirmDisabled={!memoryPreviewTitle.trim()} + labels={{ + title: t("memoryPreviewTitle"), + description: t("memoryPreviewDesc"), + cancel: tCommon("cancel"), + confirm: tCommon("confirm"), + memoryTitle: t("memoryNoteTitle"), + memoryContent: t("memoryNoteContent"), + }} + variant="terminal" + className="flex flex-col !p-0 overflow-hidden" + style={dialogStyle} + headerClassName="cursor-move select-none px-6 pt-6 pb-2" + footerClassName="px-6 pb-6 pt-2" + onHeaderPointerDown={onDragStart} + footerExtra={<ResizeHandles onEdgeResizeStart={onEdgeResizeStart} />} + /> {/* Particle effects overlay - renders on top of content but allows click-through */} <div className="pointer-events-none absolute inset-0 z-20 overflow-hidden"> diff --git a/src/components/agent/buddy-avatar.tsx b/src/components/agent/buddy-avatar.tsx new file mode 100644 index 00000000..f9768185 --- /dev/null +++ b/src/components/agent/buddy-avatar.tsx @@ -0,0 +1,158 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { SPECIES_EMOJI, RARITY_STARS, RARITY_COLORS, STAT_NAMES, type Companion } from "@/lib/agent/buddy/types"; +import { getCompanion, setMuted } from "@/lib/agent/buddy/storage"; +import { Volume2, VolumeX } from "lucide-react"; + +interface BuddyAvatarProps { + workspaceId: string; + lastAssistantMessage?: string; + onHatchRequest: () => void; +} + +export function BuddyAvatar({ workspaceId, lastAssistantMessage, onHatchRequest }: BuddyAvatarProps) { + const [companion, setCompanion] = useState<Companion | null>(() => getCompanion()); + const [reaction, setReaction] = useState<string | null>(null); + const [expanded, setExpanded] = useState(false); + const reactionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const lastReactedMsgRef = useRef<string | null>(null); + const [prevWorkspaceId, setPrevWorkspaceId] = useState(workspaceId); + + // Reload companion when workspaceId changes (React-recommended derived state pattern) + if (prevWorkspaceId !== workspaceId) { + setPrevWorkspaceId(workspaceId); + setCompanion(getCompanion()); + } + + // Fire reaction when a new assistant message arrives + useEffect(() => { + if (!companion || companion.muted || !lastAssistantMessage) return; + if (lastAssistantMessage === lastReactedMsgRef.current) return; + lastReactedMsgRef.current = lastAssistantMessage; + + // Fire-and-forget reaction request + fetch("/api/agent/buddy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "react", + lastMsg: lastAssistantMessage, + companion: { + name: companion.name, + species: companion.species, + rarity: companion.rarity, + personality: companion.personality, + stats: companion.stats, + }, + }), + }) + .then((res) => res.json()) + .then((data) => { + if (data.reaction) { + setReaction(data.reaction); + if (reactionTimeoutRef.current) clearTimeout(reactionTimeoutRef.current); + reactionTimeoutRef.current = setTimeout(() => setReaction(null), 8000); + } + }) + .catch(() => { /* non-essential */ }); + }, [lastAssistantMessage, companion]); + + // Cleanup + useEffect(() => { + return () => { + if (reactionTimeoutRef.current) clearTimeout(reactionTimeoutRef.current); + }; + }, []); + + const toggleMute = useCallback(() => { + if (!companion) return; + const newMuted = !companion.muted; + setMuted(newMuted); + setCompanion({ ...companion, muted: newMuted }); + if (newMuted) setReaction(null); + }, [companion]); + + if (!companion) { + return ( + <button + onClick={onHatchRequest} + className="flex items-center gap-1 text-[10px] text-agent-muted hover:text-agent-foreground transition-colors px-1 py-0.5 rounded" + title="Hatch a buddy companion" + > + <span className="text-sm">🥚</span> + </button> + ); + } + + const emoji = SPECIES_EMOJI[companion.species] ?? "🐾"; + const stars = RARITY_STARS[companion.rarity]; + const color = RARITY_COLORS[companion.rarity]; + + return ( + <div className="relative"> + {/* Speech bubble */} + {reaction && ( + <div className="absolute bottom-full right-0 mb-2 px-2 py-1 rounded-lg bg-[#1c2129] border border-[#30363d] text-[10px] text-[#c9d1d9] whitespace-nowrap max-w-[200px] truncate shadow-lg animate-in fade-in slide-in-from-bottom-1 z-50"> + {reaction} + <div className="absolute bottom-[-4px] right-3 w-2 h-2 bg-[#1c2129] border-r border-b border-[#30363d] rotate-45" /> + </div> + )} + + <button + onClick={() => setExpanded(!expanded)} + className="flex items-center gap-0.5 text-[10px] px-1 py-0.5 rounded transition-colors hover:bg-agent-card-hover" + title={`${companion.name} (${companion.species})`} + > + <span className="text-sm">{emoji}</span> + {companion.shiny && <span className="text-[8px]">✨</span>} + </button> + + {/* Expanded stats panel */} + {expanded && ( + <div className="absolute bottom-full right-0 mb-1 rounded-md border border-[#30363d] bg-[#161b22] p-3 text-xs font-mono z-50 min-w-[200px] shadow-lg"> + <div className="flex items-center justify-between mb-2"> + <div> + <span className="text-sm mr-1">{emoji}</span> + <span className="text-[#c9d1d9] font-semibold">{companion.name}</span> + {companion.shiny && <span className="ml-1 text-[8px]">✨ Shiny!</span>} + </div> + <button + onClick={(e) => { e.stopPropagation(); toggleMute(); }} + className="p-1 rounded hover:bg-[#30363d] text-[#8b949e]" + title={companion.muted ? "Unmute" : "Mute"} + > + {companion.muted ? <VolumeX className="h-3 w-3" /> : <Volume2 className="h-3 w-3" />} + </button> + </div> + <div className="text-[10px] mb-2" style={{ color }}> + {stars} {companion.rarity} + </div> + <div className="text-[#8b949e] text-[10px] mb-2 italic"> + {companion.personality} + </div> + <div className="space-y-1"> + {STAT_NAMES.map((stat) => { + const val = companion.stats[stat] ?? 0; + return ( + <div key={stat} className="flex items-center gap-2"> + <span className="text-[#565f89] text-[9px] w-16 text-right">{stat}</span> + <div className="flex-1 h-1.5 bg-[#21262d] rounded-full overflow-hidden"> + <div + className="h-full rounded-full transition-all" + style={{ + width: `${val}%`, + backgroundColor: val >= 80 ? "#3fb950" : val >= 60 ? "#58a6ff" : val >= 40 ? "#d2a8ff" : "#8b949e", + }} + /> + </div> + <span className="text-[#565f89] text-[9px] w-6">{val}</span> + </div> + ); + })} + </div> + </div> + )} + </div> + ); +} diff --git a/src/components/agent/buddy-hatch-dialog.tsx b/src/components/agent/buddy-hatch-dialog.tsx new file mode 100644 index 00000000..fb832d02 --- /dev/null +++ b/src/components/agent/buddy-hatch-dialog.tsx @@ -0,0 +1,158 @@ +"use client"; + +import React, { useState, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Loader2 } from "lucide-react"; +import { SPECIES_EMOJI, RARITY_STARS, RARITY_COLORS, STAT_NAMES, type CompanionBones } from "@/lib/agent/buddy/types"; +import { roll } from "@/lib/agent/buddy/companion"; +import { saveStoredCompanion } from "@/lib/agent/buddy/storage"; + +interface BuddyHatchDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onHatched: () => void; +} + +export function BuddyHatchDialog({ open, onOpenChange, onHatched }: BuddyHatchDialogProps) { + const [seed, setSeed] = useState(""); + const [bones, setBones] = useState<CompanionBones | null>(null); + const [hatching, setHatching] = useState(false); + const [rolled, setRolled] = useState(false); + + const handleRoll = useCallback(() => { + const userId = seed.trim() || `user-${Date.now()}`; + const result = roll(userId); + setBones(result.bones); + setRolled(true); + }, [seed]); + + const handleHatch = useCallback(async () => { + if (!bones) return; + setHatching(true); + + try { + const res = await fetch("/api/agent/buddy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "hatch", + species: bones.species, + rarity: bones.rarity, + stats: bones.stats, + }), + }); + + if (!res.ok) throw new Error("Hatch failed"); + + const { name, personality } = await res.json(); + + saveStoredCompanion({ + name, + personality, + hatchedAt: Date.now(), + seed: seed.trim() || `user-${Date.now()}`, + muted: false, + }); + + onHatched(); + onOpenChange(false); + setBones(null); + setRolled(false); + setSeed(""); + } catch (error) { + console.error("Failed to hatch buddy", error); + window.alert("Unable to hatch your buddy right now. Please try again."); + } finally { + setHatching(false); + } + }, [bones, seed, onHatched, onOpenChange]); + + const emoji = bones ? (SPECIES_EMOJI[bones.species] ?? "🐾") : "🥚"; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-sm"> + <DialogHeader> + <DialogTitle>Hatch a Buddy</DialogTitle> + <DialogDescription> + Your coding companion! Enter a seed (or leave blank for random). + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + <Input + value={seed} + onChange={(e) => { setSeed(e.target.value); setRolled(false); setBones(null); }} + placeholder="Seed (username, email, etc.)" + className="text-sm" + /> + + {!rolled && ( + <Button onClick={handleRoll} className="w-full"> + Roll Companion + </Button> + )} + + {bones && ( + <div className="rounded-md border border-[#30363d] bg-[#161b22] p-4 text-center space-y-3"> + <div className="text-5xl">{emoji}</div> + {bones.shiny && <div className="text-xs text-yellow-400">✨ Shiny!</div>} + <div className="text-xs font-mono" style={{ color: RARITY_COLORS[bones.rarity] }}> + {RARITY_STARS[bones.rarity]} {bones.rarity.toUpperCase()} {bones.species.toUpperCase()} + </div> + <div className="space-y-1 text-left mx-auto max-w-[180px]"> + {STAT_NAMES.map((stat) => { + const val = bones.stats[stat] ?? 0; + return ( + <div key={stat} className="flex items-center gap-2 text-[10px]"> + <span className="text-[#565f89] w-16 text-right font-mono">{stat}</span> + <div className="flex-1 h-1.5 bg-[#21262d] rounded-full overflow-hidden"> + <div + className="h-full rounded-full" + style={{ + width: `${val}%`, + backgroundColor: val >= 80 ? "#3fb950" : val >= 60 ? "#58a6ff" : val >= 40 ? "#d2a8ff" : "#8b949e", + }} + /> + </div> + <span className="text-[#565f89] w-5 text-right font-mono">{val}</span> + </div> + ); + })} + </div> + </div> + )} + </div> + + <DialogFooter> + {rolled && bones && ( + <> + <Button variant="outline" onClick={() => { setRolled(false); setBones(null); }}> + Re-roll + </Button> + <Button onClick={handleHatch} disabled={hatching}> + {hatching ? ( + <> + <Loader2 className="h-4 w-4 animate-spin mr-1" /> + Hatching... + </> + ) : ( + "Hatch!" + )} + </Button> + </> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/agent/cost-display.tsx b/src/components/agent/cost-display.tsx new file mode 100644 index 00000000..794a7685 --- /dev/null +++ b/src/components/agent/cost-display.tsx @@ -0,0 +1,96 @@ +"use client"; + +import React, { useState } from "react"; +import { DollarSign, ChevronDown, ChevronUp } from "lucide-react"; +import type { CostSnapshot } from "@/lib/agent/cost-tracker"; +import { formatTokens } from "@/lib/agent/cost-tracker"; + +interface CostDisplayProps { + snapshot: CostSnapshot | null; +} + +export function CostDisplay({ snapshot }: CostDisplayProps) { + const [expanded, setExpanded] = useState(false); + + if (!snapshot) return null; + + const hasTokenUsage = + snapshot.totalInputTokens > 0 || snapshot.totalOutputTokens > 0; + const hasUnknownPricing = Object.values(snapshot.modelUsage).some( + (usage) => !usage.pricingKnown + ); + + if (snapshot.totalCostUsd === 0 && !hasTokenUsage) return null; + + const costStr = + snapshot.totalCostUsd === 0 + ? hasUnknownPricing && hasTokenUsage + ? "~est" + : "$0.00" + : snapshot.totalCostUsd < 0.01 + ? "<$0.01" + : `$${snapshot.totalCostUsd.toFixed(2)}`; + return ( + <div className="relative"> + <button + onClick={() => setExpanded(!expanded)} + title="Session cost" + className="flex items-center gap-0.5 text-[10px] font-mono tabular-nums px-1 py-0.5 rounded select-none transition-colors text-agent-muted hover:text-agent-foreground" + > + <DollarSign className="h-3 w-3" /> + <span>{costStr}</span> + {expanded ? ( + <ChevronUp className="h-2.5 w-2.5" /> + ) : ( + <ChevronDown className="h-2.5 w-2.5" /> + )} + </button> + + {expanded && ( + <div className="absolute bottom-full right-0 mb-1 rounded-md border border-[#30363d] bg-[#161b22] p-3 text-xs font-mono z-50 min-w-[240px] shadow-lg"> + <div className="text-[#c9d1d9] font-semibold mb-2">Session Cost</div> + <div className="space-y-1 text-[#8b949e]"> + <div className="flex justify-between"> + <span>Total</span> + <span className="text-[#c9d1d9]">{costStr}</span> + </div> + <div className="flex justify-between"> + <span>Input tokens</span> + <span>{formatTokens(snapshot.totalInputTokens)}</span> + </div> + <div className="flex justify-between"> + <span>Output tokens</span> + <span>{formatTokens(snapshot.totalOutputTokens)}</span> + </div> + </div> + + {Object.keys(snapshot.modelUsage).length > 0 && ( + <> + <div className="border-t border-[#30363d] my-2" /> + <div className="text-[#8b949e] text-[10px] mb-1">By model</div> + <div className="space-y-1"> + {Object.entries(snapshot.modelUsage).map(([model, usage]) => ( + <div key={model} className="text-[10px]"> + <div className="flex justify-between text-[#8b949e]"> + <span className="truncate max-w-[140px]">{model}</span> + <span className="text-[#c9d1d9]"> + ${usage.costUsd.toFixed(4)} + </span> + </div> + <div className="flex gap-2 text-[#565f89] ml-2"> + <span>{formatTokens(usage.inputTokens)} in</span> + <span>{formatTokens(usage.outputTokens)} out</span> + {!usage.pricingKnown && ( + <span className="text-[#e0af68]">~est</span> + )} + </div> + </div> + ))} + </div> + </> + )} + </div> + )} + </div> + ); +} diff --git a/src/components/agent/memory-panel.tsx b/src/components/agent/memory-panel.tsx new file mode 100644 index 00000000..b872d1c3 --- /dev/null +++ b/src/components/agent/memory-panel.tsx @@ -0,0 +1,170 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { Brain, Sparkles, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +interface MemoryPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; +} + +interface MemoryNote { + id: string; + title: string; + content: string; + createdAt: string; +} + +export function MemoryPanel({ open, onOpenChange, workspaceId }: MemoryPanelProps) { + const [notes, setNotes] = useState<MemoryNote[]>([]); + const [loading, setLoading] = useState(false); + const [dreaming, setDreaming] = useState(false); + const [rememberText, setRememberText] = useState(""); + + const loadMemories = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(`/api/agent/memory?workspaceId=${encodeURIComponent(workspaceId)}`); + if (res.ok) { + const data = await res.json(); + setNotes(data.notes ?? []); + } + } catch { + // ignore + } finally { + setLoading(false); + } + }, [workspaceId]); + + useEffect(() => { + if (open && workspaceId) { + loadMemories(); + } + }, [open, workspaceId, loadMemories]); + + const handleRemember = async () => { + if (!rememberText.trim()) return; + try { + const res = await fetch("/api/agent/memory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId, action: "remember", text: rememberText.trim() }), + }); + if (res.ok) { + toast.success("Memory saved"); + setRememberText(""); + loadMemories(); + } else { + toast.error("Failed to save memory"); + } + } catch { + toast.error("Failed to save memory"); + } + }; + + const handleDream = async () => { + setDreaming(true); + try { + const res = await fetch("/api/agent/memory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ workspaceId, action: "dream" }), + }); + if (res.ok) { + toast.success("Dream consolidation complete"); + loadMemories(); + } else { + toast.error("Dream consolidation failed"); + } + } catch { + toast.error("Dream consolidation failed"); + } finally { + setDreaming(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-lg max-h-[80vh] flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Brain className="h-5 w-5" /> + Memory + </DialogTitle> + <DialogDescription> + Cross-session memory for this workspace. Memories persist across conversations. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto space-y-3 min-h-0"> + {loading ? ( + <div className="flex items-center justify-center py-8"> + <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /> + </div> + ) : notes.length === 0 ? ( + <div className="text-sm text-muted-foreground text-center py-8"> + No memories yet. Use <code>/remember</code> or <code><memory></code> tags to save. + </div> + ) : ( + notes.map((note) => ( + <div key={note.id} className="rounded-md border border-[#30363d] p-3 text-xs"> + <div className="font-semibold text-[#c9d1d9] mb-1">{note.title}</div> + <div className="text-[#8b949e] whitespace-pre-wrap line-clamp-4"> + {note.content} + </div> + <div className="text-[#565f89] text-[10px] mt-1"> + {new Date(note.createdAt).toLocaleString()} + </div> + </div> + )) + )} + </div> + + <div className="space-y-2 pt-2 border-t border-[#30363d]"> + <div className="flex gap-2"> + <Textarea + value={rememberText} + onChange={(e) => setRememberText(e.target.value)} + placeholder="Remember something..." + className="text-xs min-h-[60px]" + /> + </div> + <DialogFooter className="flex justify-between"> + <Button + variant="outline" + size="sm" + onClick={handleDream} + disabled={dreaming || notes.length === 0} + > + {dreaming ? ( + <Loader2 className="h-3 w-3 animate-spin mr-1" /> + ) : ( + <Sparkles className="h-3 w-3 mr-1" /> + )} + Dream + </Button> + <Button + size="sm" + onClick={handleRemember} + disabled={!rememberText.trim()} + > + Remember + </Button> + </DialogFooter> + </div> + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/agent/message-utils.ts b/src/components/agent/message-utils.ts new file mode 100644 index 00000000..5b36c972 --- /dev/null +++ b/src/components/agent/message-utils.ts @@ -0,0 +1,11 @@ +import type { UIMessage } from "ai"; + +/** Extract concatenated plain text from a UIMessage's text parts. */ +export function getMessageText(message: UIMessage): string { + return ( + message.parts + ?.filter((p): p is { type: "text"; text: string } => p.type === "text") + .map((p) => p.text) + .join("") ?? "" + ); +} diff --git a/src/components/agent/slash-command.ts b/src/components/agent/slash-command.ts index 2a974796..f2a15041 100644 --- a/src/components/agent/slash-command.ts +++ b/src/components/agent/slash-command.ts @@ -1,5 +1,20 @@ import type { Skill } from "@/types"; +/** Built-in slash commands that are handled client-side (not DB skills). */ +export interface BuiltinCommand { + slug: string; + name: string; + description: string; +} + +export const BUILTIN_COMMANDS: BuiltinCommand[] = [ + { slug: "compact", name: "Compact", description: "Compress conversation context" }, + { slug: "cost", name: "Cost", description: "Toggle session cost display" }, + { slug: "memory", name: "Memory", description: "Open memory panel" }, + { slug: "remember", name: "Remember", description: "Save a note to memory" }, + { slug: "dream", name: "Dream", description: "Consolidate memory logs" }, +]; + export function getMatchingSkillsForSlashQuery( skills: Skill[], query: string @@ -17,6 +32,16 @@ export function getMatchingSkillsForSlashQuery( ); } +export function getMatchingBuiltinCommands(query: string): BuiltinCommand[] { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) return BUILTIN_COMMANDS; + return BUILTIN_COMMANDS.filter( + (cmd) => + cmd.slug.includes(normalizedQuery) || + cmd.name.toLowerCase().includes(normalizedQuery) + ); +} + export function shouldAutocompleteCaptureEnter( showAutocomplete: boolean, matchingSkills: Skill[] diff --git a/src/components/agent/use-draggable-dialog.tsx b/src/components/agent/use-draggable-dialog.tsx new file mode 100644 index 00000000..0881d9ae --- /dev/null +++ b/src/components/agent/use-draggable-dialog.tsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect, useCallback, useRef } from "react"; + +interface DialogPosition { + x: number; + y: number; +} + +interface DialogSize { + width: number; + height: number; +} + +interface UseDraggableDialogOptions { + open: boolean; + defaultWidth?: number; + defaultHeight?: number; +} + +export function useDraggableDialog({ + open, + defaultWidth = 512, + defaultHeight = 520, +}: UseDraggableDialogOptions) { + const [dialogPos, setDialogPos] = useState<DialogPosition>({ x: 0, y: 0 }); + const [dialogSize, setDialogSize] = useState<DialogSize>({ width: defaultWidth, height: defaultHeight }); + const [isDragging, setIsDragging] = useState(false); + const [resizeEdge, setResizeEdge] = useState<string | null>(null); + const dragStartRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 }); + const resizeStartRef = useRef({ mouseX: 0, mouseY: 0, w: 0, h: 0, posX: 0, posY: 0 }); + + // Reset position/size when dialog transitions from closed to open + const prevOpenRef = useRef(false); + useEffect(() => { + if (open && !prevOpenRef.current) { + setDialogPos(() => ({ x: 0, y: 0 })); // eslint-disable-line react-hooks/set-state-in-effect + setDialogSize(() => ({ width: defaultWidth, height: defaultHeight })); + } + prevOpenRef.current = open; + }, [open, defaultWidth, defaultHeight]); + + // Global pointer listeners for dragging + useEffect(() => { + if (!isDragging) return; + const onMove = (e: PointerEvent) => { + const s = dragStartRef.current; + setDialogPos({ x: s.posX + e.clientX - s.mouseX, y: s.posY + e.clientY - s.mouseY }); + }; + const onUp = () => setIsDragging(false); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }, [isDragging]); + + // Global pointer listeners for resizing + useEffect(() => { + if (!resizeEdge) return; + const onMove = (e: PointerEvent) => { + const s = resizeStartRef.current; + const dx = e.clientX - s.mouseX; + const dy = e.clientY - s.mouseY; + let newW = s.w, newH = s.h, newX = s.posX, newY = s.posY; + + if (resizeEdge.includes("e")) newW = s.w + dx; + if (resizeEdge.includes("w")) { newW = s.w - dx; newX = s.posX + dx; } + if (resizeEdge.includes("s")) newH = s.h + dy; + if (resizeEdge.includes("n")) { newH = s.h - dy; newY = s.posY + dy; } + + if (newW < 360) { newW = 360; if (resizeEdge.includes("w")) newX = s.posX + s.w - 360; } + if (newH < 300) { newH = 300; if (resizeEdge.includes("n")) newY = s.posY + s.h - 300; } + + setDialogSize({ width: newW, height: newH }); + setDialogPos({ x: newX, y: newY }); + }; + const onUp = () => setResizeEdge(null); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + }, [resizeEdge]); + + const onDragStart = useCallback((e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + dragStartRef.current = { mouseX: e.clientX, mouseY: e.clientY, posX: dialogPos.x, posY: dialogPos.y }; + setIsDragging(true); + }, [dialogPos]); + + const onEdgeResizeStart = useCallback((e: React.PointerEvent, edge: string) => { + e.stopPropagation(); + resizeStartRef.current = { + mouseX: e.clientX, mouseY: e.clientY, + w: dialogSize.width, h: dialogSize.height, + posX: dialogPos.x, posY: dialogPos.y, + }; + setResizeEdge(edge); + }, [dialogSize, dialogPos]); + + const dialogStyle: React.CSSProperties = { + width: dialogSize.width, + height: dialogSize.height, + maxWidth: "none", + maxHeight: "none", + transform: `translate(calc(-50% + ${dialogPos.x}px), calc(-50% + ${dialogPos.y}px))`, + }; + + return { dialogStyle, onDragStart, onEdgeResizeStart }; +} + +/** Renders the 8 edge/corner resize handles for a draggable dialog. */ +export function ResizeHandles({ + onEdgeResizeStart, +}: { + onEdgeResizeStart: (e: React.PointerEvent, edge: string) => void; +}) { + return ( + <> + {/* Edge resize handles */} + <div className="absolute top-0 left-3 right-3 h-1.5 cursor-n-resize" onPointerDown={(e) => onEdgeResizeStart(e, "n")} /> + <div className="absolute bottom-0 left-3 right-3 h-1.5 cursor-s-resize" onPointerDown={(e) => onEdgeResizeStart(e, "s")} /> + <div className="absolute left-0 top-3 bottom-3 w-1.5 cursor-w-resize" onPointerDown={(e) => onEdgeResizeStart(e, "w")} /> + <div className="absolute right-0 top-3 bottom-3 w-1.5 cursor-e-resize" onPointerDown={(e) => onEdgeResizeStart(e, "e")} /> + {/* Corner resize handles */} + <div className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize" onPointerDown={(e) => onEdgeResizeStart(e, "nw")} /> + <div className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize" onPointerDown={(e) => onEdgeResizeStart(e, "ne")} /> + <div className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize" onPointerDown={(e) => onEdgeResizeStart(e, "sw")} /> + <div className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize" onPointerDown={(e) => onEdgeResizeStart(e, "se")} /> + </> + ); +} diff --git a/src/components/chat/chat-panel.tsx b/src/components/chat/chat-panel.tsx index 7896640d..826d13a4 100644 --- a/src/components/chat/chat-panel.tsx +++ b/src/components/chat/chat-panel.tsx @@ -8,8 +8,6 @@ import type { FileUIPart, UIMessage } from "ai"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Send, Bot, User, AlertCircle, Check, Circle, CheckCheck, Brain } from "lucide-react"; import ReactMarkdown from "react-markdown"; import { toast } from "sonner"; @@ -22,24 +20,27 @@ import useSWR from "swr"; import { Checkbox } from "@/components/ui/checkbox"; import { getOverflowThresholdChars, - getMessageTextLength, modelSupportsVision, } from "@/lib/ai/models"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; import { swrFetcher as fetcher } from "@/lib/fetcher"; import { createImageFileParts, extractImageFilesFromClipboard, getImageFileParts, } from "@/lib/ai/message-attachments"; +import { + buildOverflowCompactionPlan, + excludeKeptMessages, + getRenderableMessages, + requestConversationSummaryPreview, + saveConversationMemoryNote, +} from "@/lib/agent/conversation-compaction"; +import { getMessageText } from "@/components/agent/message-utils"; import { ImageAttachmentGrid } from "@/components/ui/image-attachment-grid"; +import { + ConversationMemoryPreviewDialog, + ConversationMessageSelectionDialog, +} from "@/components/conversation/conversation-compaction-dialogs"; // --- Selectable options support --- @@ -393,35 +394,18 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { if (messages.length < 4) return; if (messages.length === failedAtCountRef.current) return; - const messageSizes = messages.map((m) => getMessageTextLength(m)); - const totalChars = messageSizes.reduce((sum, s) => sum + s, 0); - if (totalChars <= overflowThreshold) return; - - // Keep newest ~20% by character count - let keepFromIndex = messages.length; - let accumulatedChars = 0; - const targetKeepChars = totalChars * 0.2; - - for (let i = messages.length - 1; i >= 0; i--) { - accumulatedChars += messageSizes[i]; - if (accumulatedChars >= targetKeepChars) { - keepFromIndex = i; - break; - } - } - - keepFromIndex = Math.min(keepFromIndex, messages.length - 2); - if (keepFromIndex <= 0) return; - - const toSummarize = messages.slice(0, keepFromIndex); - const toKeep = messages.slice(keepFromIndex); + const plan = buildOverflowCompactionPlan({ + messages, + overflowThreshold, + failedAtCount: failedAtCountRef.current, + }); + if (!plan) return; + const { toSummarize, toKeep } = plan; // Show message selection dialog instead of auto-summarizing overflowKeepRef.current = toKeep; // Only pre-select messages with renderable text content - setSelectedMessageIds(new Set( - toSummarize.filter((m) => getMessageTextLength(m) > 0).map((m) => m.id) - )); + setSelectedMessageIds(new Set(getRenderableMessages(toSummarize).map((message) => message.id))); setShowMessageSelect(true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages, status, isSummarizing, showMessageSelect, showMemoryPreview]); @@ -429,24 +413,17 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { // --- Memory dialog handlers --- const handleSelectNext = async () => { setShowMessageSelect(false); - const selected = messages.filter((m) => selectedMessageIds.has(m.id)); + const selected = messages.filter((message) => selectedMessageIds.has(message.id)); if (selected.length === 0) return; setIsSummarizing(true); try { - const res = await fetch("/api/agent/summarize", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workspaceId, - messages: selected, - trigger: "overflow", - preview: true, - locale, - }), + const data = await requestConversationSummaryPreview({ + workspaceId, + messages: selected, + trigger: "overflow", + locale, }); - if (!res.ok) throw new Error("Summarization failed"); - const data = await res.json(); setMemoryPreviewTitle(data.title); setMemoryPreviewContent(data.content); setShowMemoryPreview(true); @@ -454,7 +431,7 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { // Fall back to silent auto-summarize on preview failure if (overflowKeepRef.current) { const toKeep = overflowKeepRef.current; - const toSummarize = messages.filter((m) => !toKeep.some((k) => k.id === m.id)); + const toSummarize = excludeKeptMessages(messages, toKeep); overflowKeepRef.current = null; await summarizeAndEvict(toSummarize, toKeep); } @@ -469,7 +446,7 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { if (overflowKeepRef.current) { // User cancelled — fall back to silent auto-summarize const toKeep = overflowKeepRef.current; - const toSummarize = messages.filter((m) => !toKeep.some((k) => k.id === m.id)); + const toSummarize = excludeKeptMessages(messages, toKeep); overflowKeepRef.current = null; summarizeAndEvict(toSummarize, toKeep); } @@ -479,17 +456,11 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { setShowMemoryPreview(false); setIsSummarizing(true); try { - const res = await fetch("/api/notes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - workspaceId, - title: memoryPreviewTitle, - content: memoryPreviewContent, - type: "memory", - }), + await saveConversationMemoryNote({ + workspaceId, + title: memoryPreviewTitle, + content: memoryPreviewContent, }); - if (!res.ok) throw new Error(t("memoryError")); if (overflowKeepRef.current) { // Overflow: keep recent messages, inject memory marker @@ -505,7 +476,7 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { // On failure, fall back to silent summarize if (overflowKeepRef.current) { const toKeep = overflowKeepRef.current; - const toSummarize = messages.filter((m) => !toKeep.some((k) => k.id === m.id)); + const toSummarize = excludeKeptMessages(messages, toKeep); overflowKeepRef.current = null; await summarizeAndEvict(toSummarize, toKeep); } @@ -521,7 +492,7 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { if (overflowKeepRef.current) { // User cancelled — fall back to silent auto-summarize const toKeep = overflowKeepRef.current; - const toSummarize = messages.filter((m) => !toKeep.some((k) => k.id === m.id)); + const toSummarize = excludeKeptMessages(messages, toKeep); overflowKeepRef.current = null; summarizeAndEvict(toSummarize, toKeep); } @@ -594,16 +565,9 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { ); }; - const getMessageText = (message: (typeof messages)[number]) => { - return message.parts - ?.filter((p): p is { type: "text"; text: string } => p.type === "text") - .map((p) => p.text) - .join("") ?? ""; - }; - // Messages that have renderable text content (used for selection UI) const selectableMessages = useMemo( - () => messages.filter((m) => getMessageTextLength(m) > 0), + () => getRenderableMessages(messages), [messages] ); @@ -757,109 +721,51 @@ export function ChatPanel({ workspaceId, workspaceName }: ChatPanelProps) { </div> </div> - {/* Message Selection Dialog */} - <Dialog open={showMessageSelect} onOpenChange={(open) => { - if (!open) handleSelectCancel(); - }}> - <DialogContent className="max-w-lg max-h-[80vh] flex flex-col"> - <DialogHeader> - <DialogTitle>{t("selectMessagesTitle")}</DialogTitle> - <DialogDescription>{t("selectMessagesDesc")}</DialogDescription> - </DialogHeader> - <div className="flex gap-2 mb-2"> - <Button size="sm" variant="outline" onClick={() => setSelectedMessageIds(new Set(selectableMessages.map((m) => m.id)))}> - {t("selectAll")} - </Button> - <Button size="sm" variant="outline" onClick={() => setSelectedMessageIds(new Set())}> - {t("selectNone")} - </Button> - </div> - <ScrollArea className="flex-1 min-h-0 max-h-[50vh] pr-2"> - <div className="space-y-1.5" role="listbox" aria-multiselectable="true"> - {messages.map((msg) => { - const text = getMessageText(msg); - if (!text) return null; - const isSelected = selectedMessageIds.has(msg.id); - return ( - <div - key={msg.id} - role="option" - aria-selected={isSelected} - tabIndex={0} - className={`flex items-start gap-2 rounded-md border px-3 py-2 cursor-pointer transition-colors ${ - isSelected ? "border-primary bg-primary/5" : "border-transparent hover:bg-muted/50" - }`} - onClick={() => toggleMessage(msg.id)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleMessage(msg.id); - } - }} - > - <Checkbox - checked={isSelected} - onCheckedChange={() => toggleMessage(msg.id)} - onClick={(e: React.MouseEvent) => e.stopPropagation()} - className="mt-0.5" - /> - <div className="flex-1 min-w-0"> - <div className="text-xs font-medium text-muted-foreground mb-0.5"> - {msg.role === "user" ? t("roleUser") : t("roleAssistant")} - </div> - <div className="text-xs text-foreground line-clamp-3 whitespace-pre-wrap"> - {text.slice(0, 300)}{text.length > 300 ? "..." : ""} - </div> - </div> - </div> - ); - })} - </div> - </ScrollArea> - <DialogFooter> - <Button variant="outline" onClick={handleSelectCancel}>{t("cancel")}</Button> - <Button onClick={handleSelectNext} disabled={selectedMessageIds.size === 0}> - {t("nextStep")} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - - {/* Memory Preview Dialog */} - <Dialog open={showMemoryPreview} onOpenChange={(open) => { - if (!open) handleMemoryCancel(); - }}> - <DialogContent className="max-w-lg max-h-[80vh] flex flex-col"> - <DialogHeader> - <DialogTitle>{t("memoryPreviewTitle")}</DialogTitle> - <DialogDescription>{t("memoryPreviewDesc")}</DialogDescription> - </DialogHeader> - <div className="space-y-3 flex-1 min-h-0"> - <div> - <Label className="text-xs">{t("memoryNoteTitle")}</Label> - <Input - value={memoryPreviewTitle} - onChange={(e) => setMemoryPreviewTitle(e.target.value)} - className="mt-1" - /> - </div> - <div className="flex-1 min-h-0"> - <Label className="text-xs">{t("memoryNoteContent")}</Label> - <Textarea - value={memoryPreviewContent} - onChange={(e) => setMemoryPreviewContent(e.target.value)} - className="mt-1 min-h-[200px] max-h-[40vh] resize-none" - /> - </div> - </div> - <DialogFooter> - <Button variant="outline" onClick={handleMemoryCancel}>{t("cancel")}</Button> - <Button onClick={handleMemoryConfirm} disabled={!memoryPreviewTitle.trim()}> - {t("memoryConfirm")} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> + <ConversationMessageSelectionDialog + open={showMessageSelect} + onCancel={handleSelectCancel} + messages={messages} + selectedMessageIds={selectedMessageIds} + getMessageText={getMessageText} + onToggleMessage={toggleMessage} + onSelectAll={() => setSelectedMessageIds(new Set(selectableMessages.map((m) => m.id)))} + onSelectNone={() => setSelectedMessageIds(new Set())} + onConfirm={handleSelectNext} + labels={{ + title: t("selectMessagesTitle"), + description: t("selectMessagesDesc"), + roleUser: t("roleUser"), + roleAssistant: t("roleAssistant"), + selectAll: t("selectAll"), + selectNone: t("selectNone"), + cancel: t("cancel"), + confirm: t("nextStep"), + }} + variant="default" + className="max-w-lg max-h-[80vh] flex flex-col" + scrollAreaClassName="flex-1 min-h-0 max-h-[50vh] pr-2" + /> + + <ConversationMemoryPreviewDialog + open={showMemoryPreview} + onCancel={handleMemoryCancel} + titleValue={memoryPreviewTitle} + contentValue={memoryPreviewContent} + onTitleChange={setMemoryPreviewTitle} + onContentChange={setMemoryPreviewContent} + onConfirm={handleMemoryConfirm} + confirmDisabled={!memoryPreviewTitle.trim()} + labels={{ + title: t("memoryPreviewTitle"), + description: t("memoryPreviewDesc"), + cancel: t("cancel"), + confirm: t("memoryConfirm"), + memoryTitle: t("memoryNoteTitle"), + memoryContent: t("memoryNoteContent"), + }} + variant="default" + className="max-w-lg max-h-[80vh] flex flex-col" + /> </div> ); } diff --git a/src/components/conversation/conversation-compaction-dialogs.tsx b/src/components/conversation/conversation-compaction-dialogs.tsx new file mode 100644 index 00000000..e07e5a67 --- /dev/null +++ b/src/components/conversation/conversation-compaction-dialogs.tsx @@ -0,0 +1,284 @@ +"use client"; + +import type { CSSProperties, PointerEventHandler, ReactNode } from "react"; +import type { UIMessage } from "ai"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +type CompactionDialogVariant = "default" | "terminal"; + +interface DialogLabels { + title: string; + description: string; + roleUser?: string; + roleAssistant?: string; + selectAll?: string; + selectNone?: string; + cancel: string; + confirm: string; + clearAll?: string; + memoryTitle?: string; + memoryContent?: string; +} + +interface BaseDialogProps { + open: boolean; + onCancel: () => void; + variant?: CompactionDialogVariant; + className?: string; + style?: CSSProperties; + headerClassName?: string; + footerClassName?: string; + scrollAreaClassName?: string; + onHeaderPointerDown?: PointerEventHandler<HTMLDivElement>; + footerExtra?: ReactNode; +} + +interface MessageSelectionDialogProps extends BaseDialogProps { + messages: UIMessage[]; + selectedMessageIds: Set<string>; + getMessageText: (message: UIMessage) => string; + onToggleMessage: (id: string) => void; + onSelectAll: () => void; + onSelectNone: () => void; + onConfirm: () => void; + labels: DialogLabels; + selectedCount?: number; + totalCount?: number; + showCount?: boolean; + onClearAll?: () => void; +} + +interface MemoryPreviewDialogProps extends BaseDialogProps { + titleValue: string; + contentValue: string; + onTitleChange: (value: string) => void; + onContentChange: (value: string) => void; + onConfirm: () => void; + confirmDisabled?: boolean; + labels: DialogLabels; +} + +const terminalSelectionClasses = { + selected: "border-[#7aa2f7]/50 bg-[#7aa2f7]/5", + idle: "border-[#30363d] hover:border-[#484f58]", + roleUser: "text-[#bb9af7]", + roleAssistant: "text-[#7aa2f7]", + text: "text-[#c9d1d9]", +}; + +const defaultSelectionClasses = { + selected: "border-primary bg-primary/5", + idle: "border-transparent hover:bg-muted/50", + roleUser: "text-muted-foreground", + roleAssistant: "text-muted-foreground", + text: "text-foreground", +}; + +function getSelectionClasses(variant: CompactionDialogVariant) { + return variant === "terminal" ? terminalSelectionClasses : defaultSelectionClasses; +} + +export function ConversationMessageSelectionDialog({ + open, + onCancel, + messages, + selectedMessageIds, + getMessageText, + onToggleMessage, + onSelectAll, + onSelectNone, + onConfirm, + labels, + variant = "default", + className, + style, + headerClassName, + footerClassName, + scrollAreaClassName, + onHeaderPointerDown, + footerExtra, + selectedCount, + totalCount, + showCount = false, + onClearAll, +}: MessageSelectionDialogProps) { + const selectionClasses = getSelectionClasses(variant); + + return ( + <Dialog open={open} onOpenChange={(nextOpen) => { + if (!nextOpen) onCancel(); + }}> + <DialogContent className={className} style={style}> + <DialogHeader className={headerClassName} onPointerDown={onHeaderPointerDown}> + <DialogTitle>{labels.title}</DialogTitle> + <DialogDescription>{labels.description}</DialogDescription> + </DialogHeader> + + <div className={variant === "terminal" ? "flex items-center gap-3 px-6 pb-2" : "flex gap-2 mb-2"}> + <Button size="sm" variant="outline" onClick={onSelectAll}> + {labels.selectAll} + </Button> + <Button size="sm" variant="outline" onClick={onSelectNone}> + {labels.selectNone} + </Button> + {showCount ? ( + <span className="text-xs text-muted-foreground ml-auto"> + {selectedCount ?? selectedMessageIds.size} / {totalCount ?? messages.length} + </span> + ) : null} + </div> + + <ScrollArea className={scrollAreaClassName ?? (variant === "terminal" ? "flex-1 min-h-0 px-6" : "flex-1 min-h-0 max-h-[50vh] pr-2")}> + <div className={variant === "terminal" ? "space-y-2 py-2 pr-4" : "space-y-1.5"} role="listbox" aria-multiselectable="true"> + {messages.map((message) => { + const text = getMessageText(message); + if (!text) return null; + const isSelected = selectedMessageIds.has(message.id); + return ( + <div + key={message.id} + role="option" + aria-selected={isSelected} + tabIndex={0} + className={`flex items-start ${variant === "terminal" ? "gap-3 px-3 py-2" : "gap-2 px-3 py-2"} rounded-md border cursor-pointer transition-colors ${ + isSelected ? selectionClasses.selected : selectionClasses.idle + }`} + onClick={() => onToggleMessage(message.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onToggleMessage(message.id); + } + }} + > + <Checkbox + checked={isSelected} + onCheckedChange={() => onToggleMessage(message.id)} + onClick={(event: React.MouseEvent) => event.stopPropagation()} + className={variant === "terminal" ? "mt-0.5 shrink-0" : "mt-0.5"} + /> + <div className="min-w-0 flex-1"> + <div className={`text-xs font-medium ${variant === "default" ? "mb-0.5" : ""} ${ + message.role === "user" ? selectionClasses.roleUser : selectionClasses.roleAssistant + }`}> + {message.role === "user" ? (labels.roleUser ?? "User") : (labels.roleAssistant ?? "Assistant")} + </div> + <div className={`text-xs whitespace-pre-wrap ${variant === "terminal" ? "" : "line-clamp-3"} ${selectionClasses.text}`}> + {variant === "terminal" ? text : `${text.slice(0, 300)}${text.length > 300 ? "..." : ""}`} + </div> + </div> + </div> + ); + })} + </div> + </ScrollArea> + + <DialogFooter className={footerClassName}> + <Button variant="outline" onClick={onCancel}>{labels.cancel}</Button> + {onClearAll && labels.clearAll ? ( + <Button variant="destructive" onClick={onClearAll}> + {labels.clearAll} + </Button> + ) : null} + <Button onClick={onConfirm} disabled={selectedMessageIds.size === 0}> + {labels.confirm} + </Button> + </DialogFooter> + {footerExtra} + </DialogContent> + </Dialog> + ); +} + +export function ConversationMemoryPreviewDialog({ + open, + onCancel, + titleValue, + contentValue, + onTitleChange, + onContentChange, + onConfirm, + confirmDisabled, + labels, + variant = "default", + className, + style, + headerClassName, + footerClassName, + scrollAreaClassName, + onHeaderPointerDown, + footerExtra, +}: MemoryPreviewDialogProps) { + return ( + <Dialog open={open} onOpenChange={(nextOpen) => { + if (!nextOpen) onCancel(); + }}> + <DialogContent className={className} style={style}> + <DialogHeader className={headerClassName} onPointerDown={onHeaderPointerDown}> + <DialogTitle>{labels.title}</DialogTitle> + <DialogDescription>{labels.description}</DialogDescription> + </DialogHeader> + + {variant === "terminal" ? ( + <ScrollArea className={scrollAreaClassName ?? "flex-1 min-h-0 px-6"}> + <div className="space-y-4 py-2 pr-4"> + <div className="space-y-1.5"> + <Label>{labels.memoryTitle}</Label> + <Input value={titleValue} onChange={(event) => onTitleChange(event.target.value)} /> + </div> + <div className="space-y-1.5"> + <Label>{labels.memoryContent}</Label> + <Textarea + value={contentValue} + onChange={(event) => onContentChange(event.target.value)} + rows={12} + className="font-mono text-xs" + /> + </div> + </div> + </ScrollArea> + ) : ( + <div className="space-y-3 flex-1 min-h-0"> + <div> + <Label className="text-xs">{labels.memoryTitle}</Label> + <Input + value={titleValue} + onChange={(event) => onTitleChange(event.target.value)} + className="mt-1" + /> + </div> + <div className="flex-1 min-h-0"> + <Label className="text-xs">{labels.memoryContent}</Label> + <Textarea + value={contentValue} + onChange={(event) => onContentChange(event.target.value)} + className="mt-1 min-h-[200px] max-h-[40vh] resize-none" + /> + </div> + </div> + )} + + <DialogFooter className={footerClassName}> + <Button variant="outline" onClick={onCancel}>{labels.cancel}</Button> + <Button onClick={onConfirm} disabled={confirmDisabled}> + {labels.confirm} + </Button> + </DialogFooter> + {footerExtra} + </DialogContent> + </Dialog> + ); +} diff --git a/src/components/deep-research/artifact-display-utils.test.ts b/src/components/deep-research/artifact-display-utils.test.ts new file mode 100644 index 00000000..f7de4529 --- /dev/null +++ b/src/components/deep-research/artifact-display-utils.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeDisplayList, + truncateDisplayList, +} from "./artifact-display-utils"; + +describe("artifact-display-utils", () => { + it("normalizes author lists from strings, objects, and arrays", () => { + expect(normalizeDisplayList("Alice")).toEqual(["Alice"]); + expect(normalizeDisplayList({ name: "Bob" })).toEqual(["Bob"]); + expect(normalizeDisplayList(["Alice", { name: "Bob" }, "", { nope: true }])).toEqual(["Alice", "Bob"]); + }); + + it("truncates normalized display lists safely", () => { + expect(truncateDisplayList(["Alice", "Bob"], 4)).toBe("Alice, Bob"); + expect(truncateDisplayList(["Alice", "Bob", "Carol", "Dave", "Eve"], 4)).toBe("Alice, Bob, Carol, Dave +1 more"); + }); +}); diff --git a/src/components/deep-research/artifact-display-utils.ts b/src/components/deep-research/artifact-display-utils.ts new file mode 100644 index 00000000..799ef808 --- /dev/null +++ b/src/components/deep-research/artifact-display-utils.ts @@ -0,0 +1,35 @@ +export function normalizeDisplayList(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((item) => { + if (typeof item === "string") { + return item.trim(); + } + if (item && typeof item === "object" && "name" in item && typeof item.name === "string") { + return item.name.trim(); + } + return ""; + }) + .filter((item) => item.length > 0); + } + + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed.length > 0 ? [trimmed] : []; + } + + if (value && typeof value === "object" && "name" in value && typeof value.name === "string") { + const trimmed = value.name.trim(); + return trimmed.length > 0 ? [trimmed] : []; + } + + return []; +} + +export function truncateDisplayList(items: readonly string[], maxItems: number): string { + if (items.length <= maxItems) { + return items.join(", "); + } + + return `${items.slice(0, maxItems).join(", ")} +${items.length - maxItems} more`; +} diff --git a/src/components/deep-research/artifact-renderer-primitives.tsx b/src/components/deep-research/artifact-renderer-primitives.tsx new file mode 100644 index 00000000..daa471ab --- /dev/null +++ b/src/components/deep-research/artifact-renderer-primitives.tsx @@ -0,0 +1,115 @@ +"use client"; + +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +export function ArtifactSection({ + title, + children, + className = "space-y-2", +}: { + title: string; + children: React.ReactNode; + className?: string; +}) { + return ( + <div className={className}> + <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{title}</div> + {children} + </div> + ); +} + +export function ArtifactCard({ + children, + className = "", +}: { + children: React.ReactNode; + className?: string; +}) { + return <div className={`rounded border p-3 ${className}`.trim()}>{children}</div>; +} + +export function ArtifactNotice({ + title, + children, + tone = "muted", +}: { + title?: string; + children: React.ReactNode; + tone?: "muted" | "blue" | "green" | "yellow" | "emerald" | "slate"; +}) { + const toneStyles: Record<typeof tone, { container: string; title: string; body: string }> = { + muted: { + container: "bg-muted", + title: "text-muted-foreground", + body: "text-foreground", + }, + blue: { + container: "bg-blue-50 dark:bg-blue-950/50", + title: "text-blue-800 dark:text-blue-200", + body: "text-blue-700 dark:text-blue-300", + }, + green: { + container: "bg-green-50 dark:bg-green-950/50", + title: "text-green-800 dark:text-green-200", + body: "text-green-700 dark:text-green-300", + }, + yellow: { + container: "bg-yellow-50 dark:bg-yellow-950", + title: "text-yellow-800 dark:text-yellow-200", + body: "text-yellow-700 dark:text-yellow-300", + }, + emerald: { + container: "bg-emerald-50 dark:bg-emerald-950/40", + title: "text-emerald-800 dark:text-emerald-200", + body: "text-emerald-700 dark:text-emerald-300", + }, + slate: { + container: "bg-slate-50 dark:bg-slate-900/50", + title: "text-slate-800 dark:text-slate-200", + body: "text-slate-700 dark:text-slate-300", + }, + }; + const styles = toneStyles[tone]; + + return ( + <div className={`rounded p-2 text-xs ${styles.container}`}> + {title ? <div className={`mb-1 font-medium ${styles.title}`}>{title}</div> : null} + <div className={styles.body}>{children}</div> + </div> + ); +} + +export function SectionList({ + title, + items, + compact = false, +}: { + title: string; + items: string[]; + compact?: boolean; +}) { + if (items.length === 0) { + return null; + } + + return ( + <div> + <div className={`font-medium text-muted-foreground ${compact ? "text-[11px] mb-1" : "text-xs mb-1.5"}`}>{title}</div> + <ul className={`${compact ? "text-[11px]" : "text-xs"} space-y-0.5 list-disc pl-4 text-muted-foreground`}> + {items.map((item, index) => ( + <li key={index}>{item}</li> + ))} + </ul> + </div> + ); +} + +export function MarkdownDisplay({ text }: { text: string }) { + return ( + <div className="prose prose-sm dark:prose-invert max-w-none"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown> + </div> + ); +} diff --git a/src/components/deep-research/artifact-renderer-registry.test.ts b/src/components/deep-research/artifact-renderer-registry.test.ts new file mode 100644 index 00000000..c3fce296 --- /dev/null +++ b/src/components/deep-research/artifact-renderer-registry.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { + getMarkdownArtifactText, + resolveArtifactRendererKind, +} from "./artifact-renderer-registry"; + +describe("artifact-renderer-registry", () => { + it("maps canonical artifact types to renderer kinds", () => { + expect(resolveArtifactRendererKind("evidence_card", {})).toBe("evidence_card"); + expect(resolveArtifactRendererKind("checkpoint", {})).toBe("checkpoint"); + expect(resolveArtifactRendererKind("final_report", {})).toBe("final_report"); + }); + + it("preserves the existing markdown field priority", () => { + expect(getMarkdownArtifactText({ + report: "# Final Report", + text: "fallback", + })).toBe("fallback"); + }); +}); diff --git a/src/components/deep-research/artifact-renderer-registry.ts b/src/components/deep-research/artifact-renderer-registry.ts new file mode 100644 index 00000000..6e0b6ac5 --- /dev/null +++ b/src/components/deep-research/artifact-renderer-registry.ts @@ -0,0 +1,86 @@ +export type ArtifactRendererKind = + | "task_board" + | "collaboration_packet" + | "role_specification" + | "protocol_graph" + | "research_brief" + | "evidence_card" + | "structured_summary" + | "reviewer_packet" + | "review_assessment" + | "main_brain_audit" + | "provisional_conclusion" + | "validation_plan" + | "execution_manifest" + | "execution_plan" + | "step_result" + | "memory_profile" + | "memory_snapshot" + | "memory_index" + | "final_report" + | "task_graph" + | "checkpoint" + | "json"; + +const ARTIFACT_RENDERER_KIND_BY_TYPE: Partial<Record<string, ArtifactRendererKind>> = { + research_brief: "research_brief", + evidence_card: "evidence_card", + structured_summary: "structured_summary", + literature_round_summary: "structured_summary", + reviewer_packet: "reviewer_packet", + review_assessment: "review_assessment", + main_brain_audit: "main_brain_audit", + provisional_conclusion: "provisional_conclusion", + validation_plan: "validation_plan", + execution_manifest: "execution_manifest", + execution_plan: "execution_plan", + step_result: "step_result", + experiment_result: "step_result", + memory_profile: "memory_profile", + memory_snapshot: "memory_snapshot", + memory_index: "memory_index", + final_report: "final_report", + task_graph: "task_graph", + checkpoint: "checkpoint", +}; + +export function resolveArtifactRendererKind(type: string, content: Record<string, unknown>): ArtifactRendererKind { + if (looksLikeTaskBoard(content)) return "task_board"; + if (looksLikeCollaborationPacket(content)) return "collaboration_packet"; + if (looksLikeRoleSpecification(content)) return "role_specification"; + if (looksLikeProtocolGraph(content)) return "protocol_graph"; + + return ARTIFACT_RENDERER_KIND_BY_TYPE[type] ?? "json"; +} + +export function getMarkdownArtifactText(content: Record<string, unknown>): string { + return content.text as string + || content.report as string + || content.messageToUser as string + || content.content as string + || JSON.stringify(content, null, 2); +} + +function looksLikeTaskBoard(content: Record<string, unknown>): boolean { + return typeof content.objective === "string" + && Array.isArray(content.assignments) + && typeof content.coordinatorRoleId === "string"; +} + +function looksLikeCollaborationPacket(content: Record<string, unknown>): boolean { + return typeof content.roleName === "string" + && typeof content.workflowSegment === "string" + && content.packet != null + && typeof content.packet === "object"; +} + +function looksLikeRoleSpecification(content: Record<string, unknown>): boolean { + return typeof content.roleName === "string" + && typeof content.workflowSegment === "string" + && Array.isArray(content.prompts) + && Array.isArray(content.skills); +} + +function looksLikeProtocolGraph(content: Record<string, unknown>): boolean { + return Array.isArray(content.roles) && Array.isArray(content.protocols); +} diff --git a/src/components/deep-research/artifact-renderers/analysis-renderers.tsx b/src/components/deep-research/artifact-renderers/analysis-renderers.tsx new file mode 100644 index 00000000..24d33b03 --- /dev/null +++ b/src/components/deep-research/artifact-renderers/analysis-renderers.tsx @@ -0,0 +1,264 @@ +import { Badge } from "@/components/ui/badge"; +import { + ArtifactCard, + ArtifactNotice, + ArtifactSection, +} from "../artifact-renderer-primitives"; + +export function ReviewerPacketDisplay({ data }: { data: Record<string, unknown> }) { + const verdict = data.verdict as string; + const verdictColors: Record<string, string> = { + approve: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + revise: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + reject: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + }; + + return ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Badge className={verdictColors[verdict] || ""}> + {verdict} + </Badge> + <span className="text-xs text-muted-foreground"> + Confidence: {((data.confidence as number) * 100).toFixed(0)}% + </span> + </div> + <div className="text-sm">{data.critique as string}</div> + {Array.isArray(data.suggestions) && data.suggestions.length > 0 && ( + <div> + <div className="text-xs font-medium text-muted-foreground mb-1">Suggestions</div> + <ul className="list-disc list-inside text-sm space-y-0.5"> + {(data.suggestions as string[]).map((suggestion, index) => ( + <li key={index}>{suggestion}</li> + ))} + </ul> + </div> + )} + </div> + ); +} + +export function ExecutionPlanDisplay({ data }: { data: Record<string, unknown> }) { + const steps = Array.isArray(data.steps) ? data.steps : []; + return ( + <div className="space-y-2"> + {steps.map((step: Record<string, unknown>, index: number) => ( + <div key={index} className="flex items-start gap-2 text-sm p-2 border rounded"> + <span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0"> + {index + 1} + </span> + <div className="flex-1"> + <div className="font-medium">{String(step.label || step.description || "")}</div> + {step.requiresApproval ? ( + <Badge variant="outline" className="text-[10px] mt-1">Needs Approval</Badge> + ) : null} + </div> + </div> + ))} + {steps.length === 0 && ( + <pre className="text-xs bg-muted p-3 rounded overflow-auto">{JSON.stringify(data, null, 2)}</pre> + )} + </div> + ); +} + +export function StepResultDisplay({ data }: { data: Record<string, unknown> }) { + const statusColors: Record<string, string> = { + success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + failure: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + partial: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + }; + + return ( + <div className="space-y-2"> + <Badge className={statusColors[data.status as string] || ""}> + {data.status as string} + </Badge> + {Array.isArray(data.observations) && ( + <ul className="list-disc list-inside text-sm space-y-0.5"> + {(data.observations as string[]).map((observation, index) => ( + <li key={index}>{observation}</li> + ))} + </ul> + )} + {data.outputs != null && ( + <pre className="text-xs bg-muted p-2 rounded overflow-auto"> + {JSON.stringify(data.outputs, null, 2)} + </pre> + )} + </div> + ); +} + +export function ReviewAssessmentDisplay({ data }: { data: Record<string, unknown> }) { + const verdict = data.combinedVerdict as string; + const reviewerSummary = (data.reviewerSummary as string) || ""; + const reviewHighlights = Array.isArray(data.reviewHighlights) + ? data.reviewHighlights as string[] + : []; + const openIssues = Array.isArray(data.openIssues) + ? data.openIssues as string[] + : []; + const verdictColors: Record<string, string> = { + approve: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + revise: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + reject: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + }; + + return ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Badge className={verdictColors[verdict] || ""}> + {verdict} + </Badge> + <span className="text-xs text-muted-foreground"> + Reviewer confidence: {((data.combinedConfidence as number) * 100).toFixed(0)}% + </span> + </div> + + {reviewerSummary && ( + <ArtifactNotice title="Results and Evidence Analyst" tone="blue"> + {reviewerSummary} + </ArtifactNotice> + )} + + {reviewHighlights.length > 0 && ( + <div className="text-xs"> + <div className="mb-1 font-medium text-green-700 dark:text-green-300">Review Highlights</div> + <ul className="list-disc list-inside text-xs space-y-0.5"> + {reviewHighlights.map((item, index) => <li key={index}>{item}</li>)} + </ul> + </div> + )} + + {openIssues.length > 0 && ( + <div> + <div className="text-xs font-medium text-red-700 dark:text-red-300 mb-1">Open Issues</div> + <ul className="list-disc list-inside text-xs space-y-0.5"> + {openIssues.map((item, index) => <li key={index}>{item}</li>)} + </ul> + </div> + )} + + <div className="flex flex-wrap gap-2"> + {Boolean(data.needsMoreLiterature) && ( + <Badge variant="outline" className="text-[10px] text-amber-600">Needs More Literature</Badge> + )} + {Boolean(data.needsExperimentalValidation) && ( + <Badge variant="outline" className="text-[10px] text-purple-600">Needs Experiments</Badge> + )} + </div> + </div> + ); +} + +export function MainBrainAuditDisplay({ data }: { data: Record<string, unknown> }) { + const assessment = data.resultAssessment as string; + const assessmentColors: Record<string, string> = { + good: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + acceptable: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + concerning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", + problematic: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + }; + + return ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Badge className={assessmentColors[assessment] || ""}> + {assessment} + </Badge> + {Boolean(data.canProceed) ? ( + <Badge variant="outline" className="text-[10px] text-green-600">Can Proceed</Badge> + ) : ( + <Badge variant="outline" className="text-[10px] text-red-600">Cannot Proceed</Badge> + )} + </div> + + <div className="text-sm">{data.whatWasCompleted as string}</div> + + {Array.isArray(data.issuesAndRisks) && data.issuesAndRisks.length > 0 && ( + <ArtifactNotice title="Issues & Risks" tone="yellow"> + <ul className="list-disc list-inside space-y-0.5"> + {(data.issuesAndRisks as string[]).map((risk, index) => <li key={index}>{risk}</li>)} + </ul> + </ArtifactNotice> + )} + + {typeof data.continueWillDo === "string" && data.continueWillDo && ( + <ArtifactNotice tone="green"> + <span className="font-medium">Continue will: </span> + <span>{data.continueWillDo}</span> + </ArtifactNotice> + )} + + {Array.isArray(data.alternativeActions) && data.alternativeActions.length > 0 && ( + <div className="text-xs space-y-1"> + <div className="font-medium text-muted-foreground">Alternatives</div> + {(data.alternativeActions as Array<{ label: string; description: string }>).map((alternative, index) => ( + <div key={index} className="p-1.5 bg-muted rounded"> + <span className="font-medium">{alternative.label}:</span> {alternative.description} + </div> + ))} + </div> + )} + </div> + ); +} + +export function ValidationPlanDisplay({ data }: { data: Record<string, unknown> }) { + const steps = Array.isArray(data.steps) ? data.steps : []; + return ( + <div className="space-y-3"> + {typeof data.objective === "string" && data.objective && ( + <div className="text-sm"><span className="font-medium">Objective:</span> {data.objective}</div> + )} + {typeof data.hypothesis === "string" && data.hypothesis && ( + <div className="text-sm"><span className="font-medium">Hypothesis:</span> {data.hypothesis}</div> + )} + {steps.length > 0 && ( + <ArtifactSection title="Steps"> + {steps.map((step: Record<string, unknown>, index: number) => ( + <ArtifactCard key={index} className="flex items-start gap-2 p-2 text-xs"> + <span className="font-mono bg-muted px-1.5 py-0.5 rounded shrink-0">{Number(step.stepNumber) || index + 1}</span> + <div className="flex-1"> + <div className="font-medium">{String(step.description || "")}</div> + {typeof step.command === "string" && step.command && <code className="text-[10px] text-muted-foreground">{step.command}</code>} + {Boolean(step.requiresApproval) && <Badge variant="outline" className="text-[10px] mt-1">Needs Approval</Badge>} + </div> + </ArtifactCard> + ))} + </ArtifactSection> + )} + {Array.isArray(data.successCriteria) && data.successCriteria.length > 0 && ( + <div className="text-xs"> + <div className="font-medium text-green-700 dark:text-green-300 mb-1">Success Criteria</div> + <ul className="list-disc list-inside space-y-0.5"> + {(data.successCriteria as string[]).map((criterion, index) => <li key={index}>{criterion}</li>)} + </ul> + </div> + )} + </div> + ); +} + +export function ExecutionManifestDisplay({ data }: { data: Record<string, unknown> }) { + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-[10px]">{String(data.launcherType)}</Badge> + {typeof data.purpose === "string" && data.purpose && <span className="text-xs text-muted-foreground">{data.purpose}</span>} + </div> + <div className="grid grid-cols-3 gap-2 text-xs"> + {data.gpu != null && <div><span className="text-muted-foreground">GPU:</span> {String(data.gpu)}</div>} + {data.memoryMb != null && <div><span className="text-muted-foreground">Memory:</span> {String(data.memoryMb)}MB</div>} + {data.cpu != null && <div><span className="text-muted-foreground">CPU:</span> {String(data.cpu)}</div>} + </div> + {typeof data.command === "string" && data.command && ( + <pre className="text-xs bg-muted p-2 rounded overflow-auto">{data.command}</pre> + )} + {typeof data.chargedGroup === "string" && data.chargedGroup && ( + <div className="text-[10px] text-muted-foreground">Charged to: {data.chargedGroup}</div> + )} + </div> + ); +} diff --git a/src/components/deep-research/artifact-renderers/evidence-renderers.tsx b/src/components/deep-research/artifact-renderers/evidence-renderers.tsx new file mode 100644 index 00000000..4aedd1ca --- /dev/null +++ b/src/components/deep-research/artifact-renderers/evidence-renderers.tsx @@ -0,0 +1,354 @@ +import type { + EvidenceRetrievalStatus, + RawExcerpt, + SourceEntry, +} from "@/lib/deep-research/types"; +import { Badge } from "@/components/ui/badge"; +import { + normalizeDisplayList, + truncateDisplayList, +} from "../artifact-display-utils"; +import { + ArtifactCard, + ArtifactNotice, + ArtifactSection, + MarkdownDisplay, + SectionList, +} from "../artifact-renderer-primitives"; + +export function EvidenceCardDisplay({ + data, + excerptLimit, +}: { + data: Record<string, unknown>; + excerptLimit: number; +}) { + const query = typeof data.query === "string" ? data.query : "Evidence retrieval"; + const retrievalStatus = typeof data.retrievalStatus === "string" ? data.retrievalStatus : "empty"; + const retrievalNotes = typeof data.retrievalNotes === "string" ? data.retrievalNotes : ""; + const coverageSummary = typeof data.coverageSummary === "string" ? data.coverageSummary : ""; + const recoveredFrom = typeof data.recoveredFrom === "string" ? data.recoveredFrom : ""; + const sourcesFound = typeof data.sourcesFound === "number" + ? data.sourcesFound + : typeof data.totalFound === "number" + ? data.totalFound + : 0; + const sourcesAttempted = typeof data.sourcesAttempted === "number" ? data.sourcesAttempted : undefined; + const searchQueries = Array.isArray(data.searchQueries) + ? data.searchQueries.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const sources = Array.isArray(data.sources) + ? data.sources.filter(isEvidenceSource) + : []; + const rawExcerpts = Array.isArray(data.rawExcerpts) + ? data.rawExcerpts.filter(isEvidenceExcerpt) + : []; + const normalizedExcerptLimit = Number.isFinite(excerptLimit) ? Math.max(excerptLimit, 0) : rawExcerpts.length; + const visibleExcerpts = rawExcerpts.slice(0, normalizedExcerptLimit); + const hiddenExcerptCount = rawExcerpts.length - visibleExcerpts.length; + + return ( + <div className="space-y-4"> + <ArtifactCard className="space-y-3"> + <div className="space-y-1"> + <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Research Query</div> + <div className="text-sm font-medium leading-relaxed">{query}</div> + </div> + + <div className="flex flex-wrap items-center gap-2"> + <Badge className={evidenceStatusColors[normalizeEvidenceStatus(retrievalStatus)]}> + {formatEvidenceStatus(retrievalStatus)} + </Badge> + <Badge variant="outline" className="text-[10px]"> + {sourcesFound} source{sourcesFound === 1 ? "" : "s"} + {typeof sourcesAttempted === "number" ? ` found / ${sourcesAttempted} attempted` : " found"} + </Badge> + <Badge variant="secondary" className="text-[10px]"> + {rawExcerpts.length} excerpt{rawExcerpts.length === 1 ? "" : "s"} + </Badge> + </div> + + {retrievalNotes ? ( + <ArtifactNotice title="Retrieval Notes" tone={sourcesFound > 0 ? "blue" : "yellow"}> + {retrievalNotes} + </ArtifactNotice> + ) : null} + + {coverageSummary ? ( + <div className="text-xs text-muted-foreground">{coverageSummary}</div> + ) : null} + + {searchQueries.length > 0 ? ( + <div className="space-y-1.5"> + <div className="text-xs font-medium text-muted-foreground">Search terms</div> + <div className="flex flex-wrap gap-1.5"> + {searchQueries.map((searchQuery, index) => ( + <Badge key={`${searchQuery}-${index}`} variant="outline" className="max-w-full text-[10px]"> + <span className="truncate">{searchQuery}</span> + </Badge> + ))} + </div> + </div> + ) : null} + + {recoveredFrom ? ( + <div className="text-[11px] text-muted-foreground"> + Evidence card was reconstructed from {recoveredFrom.replace(/_/g, " ")}. + </div> + ) : null} + </ArtifactCard> + + {sources.length > 0 ? ( + <ArtifactSection title={`Sources (${sources.length})`}> + <div className="space-y-2"> + {sources.map((source, index) => { + const authorNames = normalizeDisplayList(source.authors); + return ( + <ArtifactCard key={`${source.title}-${index}`} className="space-y-2"> + <div className="flex items-start justify-between gap-3"> + <div className="min-w-0 space-y-1"> + <div className="text-sm font-medium leading-snug"> + {source.url ? ( + <a + href={source.url} + target="_blank" + rel="noreferrer" + className="underline-offset-2 hover:underline" + > + {source.title} + </a> + ) : ( + source.title + )} + </div> + <div className="text-xs text-muted-foreground"> + {formatSourceMetadata(source)} + </div> + </div> + <Badge variant="outline" className="shrink-0 text-[10px]"> + Source {index + 1} + </Badge> + </div> + + {authorNames.length > 0 ? ( + <div className="text-xs text-muted-foreground"> + {truncateDisplayList(authorNames, 4)} + </div> + ) : null} + + {source.doi ? ( + <div className="text-[11px] text-muted-foreground"> + DOI: {source.doi} + </div> + ) : null} + </ArtifactCard> + ); + })} + </div> + </ArtifactSection> + ) : ( + <ArtifactNotice title="No Sources Retrieved" tone="yellow"> + This evidence step did not return any usable sources. + </ArtifactNotice> + )} + + {visibleExcerpts.length > 0 ? ( + <ArtifactSection title={`Key Excerpts (${rawExcerpts.length})`}> + <div className="space-y-2"> + {visibleExcerpts.map((excerpt, index) => { + const source = sources[excerpt.sourceIndex]; + const location = [excerpt.section, excerpt.page].filter(Boolean).join(" - "); + + return ( + <ArtifactCard key={`${excerpt.sourceIndex}-${index}`} className="space-y-2"> + <div className="text-sm leading-relaxed text-foreground"> + {truncateText(excerpt.text, 360)} + </div> + <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> + <Badge variant="secondary" className="text-[10px]"> + {source?.title ?? `Source ${excerpt.sourceIndex + 1}`} + </Badge> + {location ? <span>{location}</span> : null} + </div> + </ArtifactCard> + ); + })} + </div> + {hiddenExcerptCount > 0 ? ( + <div className="text-[11px] text-muted-foreground"> + Showing first {visibleExcerpts.length} excerpts. {hiddenExcerptCount} more excerpt{hiddenExcerptCount === 1 ? "" : "s"} remain in the raw artifact. + </div> + ) : null} + </ArtifactSection> + ) : null} + </div> + ); +} + +export function StructuredSummaryDisplay({ data }: { data: Record<string, unknown> }) { + const chapterPackets = Array.isArray(data.chapterPackets) + ? data.chapterPackets.filter((packet): packet is Record<string, unknown> => Boolean(packet) && typeof packet === "object") + : []; + const crossSectionThemes = Array.isArray(data.crossSectionThemes) + ? data.crossSectionThemes.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const globalOpenQuestions = Array.isArray(data.globalOpenQuestions) + ? data.globalOpenQuestions.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + + if (chapterPackets.length === 0) { + return <MarkdownDisplay text={data.summary as string || JSON.stringify(data, null, 2)} />; + } + + return ( + <div className="space-y-3"> + {typeof data.summary === "string" && data.summary.trim().length > 0 ? ( + <ArtifactCard className="text-sm leading-relaxed"> + {data.summary} + </ArtifactCard> + ) : null} + + {chapterPackets.map((packet, index) => { + const title = typeof packet.title === "string" ? packet.title : `Chapter Packet ${index + 1}`; + const takeaways = Array.isArray(packet.keyTakeaways) + ? packet.keyTakeaways.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const citationKeys = Array.isArray(packet.citationKeys) + ? packet.citationKeys.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const openQuestions = Array.isArray(packet.openQuestions) + ? packet.openQuestions.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const claims = Array.isArray(packet.claims) + ? packet.claims.filter((claim): claim is Record<string, unknown> => Boolean(claim) && typeof claim === "object") + : []; + + return ( + <ArtifactCard key={`${title}-${index}`} className="space-y-2"> + <div className="flex items-start justify-between gap-3"> + <div className="space-y-1 min-w-0"> + <div className="text-sm font-medium leading-snug">{title}</div> + {typeof packet.summary === "string" && packet.summary.trim().length > 0 ? ( + <div className="text-xs text-muted-foreground">{packet.summary}</div> + ) : null} + </div> + <Badge variant="outline" className="text-[10px] shrink-0"> + Packet {index + 1} + </Badge> + </div> + + {takeaways.length > 0 ? ( + <SectionList title="Key Takeaways" items={takeaways} compact /> + ) : null} + + {claims.length > 0 ? ( + <ArtifactSection title={`Claims (${claims.length})`}> + <div className="space-y-2"> + {claims.slice(0, 6).map((claim, claimIndex) => ( + <ArtifactCard key={`${title}-claim-${claimIndex}`} className="space-y-1"> + <div className="text-sm">{typeof claim.text === "string" ? claim.text : JSON.stringify(claim)}</div> + {typeof claim.strength === "string" ? ( + <div className="text-[11px] text-muted-foreground">Strength: {claim.strength}</div> + ) : null} + </ArtifactCard> + ))} + </div> + </ArtifactSection> + ) : null} + + {citationKeys.length > 0 ? ( + <SectionList title="Citation Keys" items={citationKeys} compact /> + ) : null} + + {openQuestions.length > 0 ? ( + <SectionList title="Open Questions" items={openQuestions} compact /> + ) : null} + + {typeof packet.recommendedSectionText === "string" && packet.recommendedSectionText.trim().length > 0 ? ( + <ArtifactSection title="Section Seed"> + <MarkdownDisplay text={packet.recommendedSectionText} /> + </ArtifactSection> + ) : null} + </ArtifactCard> + ); + })} + + {crossSectionThemes.length > 0 ? ( + <SectionList title="Cross-Section Themes" items={crossSectionThemes} /> + ) : null} + + {globalOpenQuestions.length > 0 ? ( + <SectionList title="Global Open Questions" items={globalOpenQuestions} /> + ) : null} + </div> + ); +} + +const evidenceStatusColors: Record<EvidenceRetrievalStatus, string> = { + success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", + partial: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", + insufficient_evidence: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200", + failed_retrieval: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", + empty: "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200", +}; + +function isEvidenceSource(value: unknown): value is SourceEntry { + return Boolean( + value + && typeof value === "object" + && typeof (value as SourceEntry).title === "string" + && typeof (value as SourceEntry).url === "string", + ); +} + +function isEvidenceExcerpt(value: unknown): value is RawExcerpt { + return Boolean( + value + && typeof value === "object" + && typeof (value as RawExcerpt).text === "string" + && typeof (value as RawExcerpt).sourceIndex === "number", + ); +} + +function formatEvidenceStatus(status: string): string { + switch (status) { + case "success": + return "Retrieved"; + case "partial": + return "Partial"; + case "insufficient_evidence": + return "Limited Evidence"; + case "failed_retrieval": + return "Retrieval Failed"; + case "empty": + return "No Results"; + default: + return status; + } +} + +function normalizeEvidenceStatus(status: string): EvidenceRetrievalStatus { + switch (status) { + case "success": + case "partial": + case "insufficient_evidence": + case "failed_retrieval": + case "empty": + return status; + default: + return "empty"; + } +} + +function formatSourceMetadata(source: SourceEntry): string { + const parts = [source.year?.toString(), source.venue].filter(Boolean); + return parts.length > 0 ? parts.join(" • ") : "Source metadata unavailable"; +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + + return `${text.slice(0, maxLength).trimEnd()}...`; +} diff --git a/src/components/deep-research/artifact-renderers/memory-renderers.tsx b/src/components/deep-research/artifact-renderers/memory-renderers.tsx new file mode 100644 index 00000000..10e83b33 --- /dev/null +++ b/src/components/deep-research/artifact-renderers/memory-renderers.tsx @@ -0,0 +1,163 @@ +import { Badge } from "@/components/ui/badge"; +import { + ArtifactCard, + ArtifactNotice, + MarkdownDisplay, + SectionList, +} from "../artifact-renderer-primitives"; + +export function MemoryProfileDisplay({ data }: { data: Record<string, unknown> }) { + const activeRequirements = Array.isArray(data.activeRequirements) ? data.activeRequirements as string[] : []; + const activeConstraints = Array.isArray(data.activeConstraints) ? data.activeConstraints as string[] : []; + const openQuestions = Array.isArray(data.openQuestions) ? data.openQuestions as string[] : []; + const activeHypotheses = Array.isArray(data.activeHypotheses) ? data.activeHypotheses as string[] : []; + const keyDecisions = Array.isArray(data.keyDecisions) ? data.keyDecisions as string[] : []; + + return ( + <div className="space-y-3"> + <ArtifactCard className="space-y-2"> + <div className="text-sm font-medium">{String(data.objective ?? "No objective recorded")}</div> + <div className="flex flex-wrap gap-2"> + {typeof data.currentPhase === "string" && ( + <Badge variant="outline" className="text-[10px]"> + {String(data.currentPhase)} + </Badge> + )} + {typeof data.latestCheckpointTitle === "string" && data.latestCheckpointTitle && ( + <Badge variant="secondary" className="text-[10px]"> + {String(data.latestCheckpointTitle)} + </Badge> + )} + </div> + {typeof data.latestRecommendedNextAction === "string" && data.latestRecommendedNextAction && ( + <ArtifactNotice tone="green" title="Latest Next Action"> + {String(data.latestRecommendedNextAction)} + </ArtifactNotice> + )} + {typeof data.latestPlanSummary === "string" && data.latestPlanSummary && ( + <div className="text-xs text-muted-foreground">{String(data.latestPlanSummary)}</div> + )} + </ArtifactCard> + + <SectionList title="Active Requirements" items={activeRequirements} /> + <SectionList title="Active Constraints" items={activeConstraints} /> + <SectionList title="Open Questions" items={openQuestions} /> + <SectionList title="Active Hypotheses" items={activeHypotheses} /> + <SectionList title="Key Decisions" items={keyDecisions} /> + </div> + ); +} + +export function MemorySnapshotDisplay({ data }: { data: Record<string, unknown> }) { + const acceptedFacts = Array.isArray(data.acceptedFacts) ? data.acceptedFacts as string[] : []; + const contestedFacts = Array.isArray(data.contestedFacts) ? data.contestedFacts as string[] : []; + const unresolvedGaps = Array.isArray(data.unresolvedGaps) ? data.unresolvedGaps as string[] : []; + const focusAreas = Array.isArray(data.focusAreas) ? data.focusAreas as string[] : []; + + return ( + <div className="space-y-3"> + <ArtifactCard className="space-y-2"> + <div className="text-sm font-medium">{String(data.title ?? "Memory Snapshot")}</div> + {typeof data.summary === "string" && data.summary && ( + <div className="text-sm leading-relaxed">{String(data.summary)}</div> + )} + {typeof data.nextStep === "string" && data.nextStep && ( + <ArtifactNotice title="Next Step" tone="blue"> + {String(data.nextStep)} + </ArtifactNotice> + )} + {focusAreas.length > 0 && ( + <div className="flex flex-wrap gap-1.5"> + {focusAreas.map((focusArea, index) => ( + <Badge key={`${focusArea}-${index}`} variant="outline" className="text-[10px]"> + {focusArea} + </Badge> + ))} + </div> + )} + </ArtifactCard> + + <SectionList title="Accepted Facts" items={acceptedFacts} /> + <SectionList title="Contested Facts" items={contestedFacts} /> + <SectionList title="Unresolved Gaps" items={unresolvedGaps} /> + </div> + ); +} + +export function MemoryIndexDisplay({ data }: { data: Record<string, unknown> }) { + const items = Array.isArray(data.items) ? data.items as Array<Record<string, unknown>> : []; + const sourceOfTruth = typeof data.sourceOfTruth === "string" ? data.sourceOfTruth : ""; + + return ( + <div className="space-y-3"> + <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> + {typeof data.itemCount === "number" && ( + <Badge variant="outline" className="text-[10px]"> + {Number(data.itemCount)} item(s) + </Badge> + )} + {Boolean(data.stats && typeof data.stats === "object") && ( + <span>{JSON.stringify(data.stats)}</span> + )} + {sourceOfTruth && ( + <span>source-of-truth: {sourceOfTruth}</span> + )} + </div> + + <div className="space-y-2"> + {items.map((item, index) => ( + <ArtifactCard key={String(item.id ?? index)} className="space-y-2"> + <div className="flex flex-wrap items-center gap-2"> + <div className="text-sm font-medium">{String(item.title ?? "Untitled memory")}</div> + {typeof item.kind === "string" && ( + <Badge variant="secondary" className="text-[10px]"> + {String(item.kind)} + </Badge> + )} + {typeof item.category === "string" && ( + <Badge variant="outline" className="text-[10px]"> + {String(item.category)} + </Badge> + )} + </div> + <div className="text-xs text-muted-foreground leading-relaxed"> + {String(item.summary ?? "")} + </div> + {Array.isArray(item.anchors) && (item.anchors as Array<Record<string, unknown>>).length > 0 && ( + <div className="text-[11px] text-muted-foreground"> + Anchor: {(item.anchors as Array<Record<string, unknown>>).slice(0, 2).map((anchor) => { + const parts: string[] = []; + if (typeof anchor.artifactType === "string") parts.push(String(anchor.artifactType)); + if (typeof anchor.artifactId === "string") parts.push(String(anchor.artifactId)); + if (typeof anchor.messageId === "string") parts.push(`message:${String(anchor.messageId)}`); + if (typeof anchor.sourceIndex === "number") parts.push(`source#${Number(anchor.sourceIndex) + 1}`); + if (typeof anchor.excerptIndex === "number") parts.push(`excerpt#${Number(anchor.excerptIndex) + 1}`); + if (typeof anchor.claimId === "string") parts.push(`claim:${String(anchor.claimId)}`); + if (typeof anchor.gapIndex === "number") parts.push(`gap#${Number(anchor.gapIndex) + 1}`); + if (typeof anchor.field === "string") parts.push(String(anchor.field)); + return parts.join(" / "); + }).filter(Boolean).join(" | ")} + </div> + )} + <div className="flex flex-wrap gap-1.5"> + {Array.isArray(item.tags) && (item.tags as string[]).slice(0, 6).map((tag, tagIndex) => ( + <Badge key={`${tag}-${tagIndex}`} variant="outline" className="text-[10px]"> + {tag} + </Badge> + ))} + </div> + </ArtifactCard> + ))} + </div> + </div> + ); +} + +export function FinalReportMarkdownDisplay({ content }: { content: Record<string, unknown> }) { + const text = content.text as string + || content.report as string + || content.messageToUser as string + || content.content as string + || JSON.stringify(content, null, 2); + return <MarkdownDisplay text={text} />; +} diff --git a/src/components/deep-research/artifact-renderers/workflow-renderers.tsx b/src/components/deep-research/artifact-renderers/workflow-renderers.tsx new file mode 100644 index 00000000..0ebb233c --- /dev/null +++ b/src/components/deep-research/artifact-renderers/workflow-renderers.tsx @@ -0,0 +1,426 @@ +import type { NodeDispatchPreview } from "@/lib/deep-research/node-spec-templates"; +import { Badge } from "@/components/ui/badge"; +import { + ArtifactCard, + ArtifactNotice, + ArtifactSection, + SectionList, +} from "../artifact-renderer-primitives"; + +export function RoleSpecificationDisplay({ data }: { data: Record<string, unknown> }) { + const prompts = Array.isArray(data.prompts) ? data.prompts as Array<Record<string, unknown>> : []; + const skills = Array.isArray(data.skills) ? data.skills as Array<Record<string, unknown>> : []; + const collaborations = Array.isArray(data.collaborations) ? data.collaborations as Array<Record<string, unknown>> : []; + const responsibilities = Array.isArray(data.coreResponsibilities) ? data.coreResponsibilities as string[] : []; + const standards = Array.isArray(data.performanceStandards) ? data.performanceStandards as string[] : []; + + return ( + <div className="space-y-4"> + <div className="space-y-1"> + <div className="text-sm font-semibold">{String(data.roleName)}</div> + <div className="text-xs text-muted-foreground">{String(data.workflowSegment)}</div> + <p className="text-sm leading-relaxed">{String(data.corePositioning ?? "")}</p> + </div> + + <SectionList title="Core Responsibilities" items={responsibilities} /> + <SectionList title="Performance Standards" items={standards} /> + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Prompts</div> + <div className="space-y-2"> + {prompts.map((item, index) => ( + <div key={index} className="rounded border p-3 space-y-1.5"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-medium">{String(item.title ?? "")}</span> + <Badge variant="outline" className="text-[10px]">{String(item.kind ?? "")}</Badge> + </div> + <p className="text-xs text-muted-foreground">{String(item.objective ?? "")}</p> + <SectionList title="Required Sections" items={Array.isArray(item.requiredSections) ? item.requiredSections as string[] : []} compact /> + <SectionList title="Constraints" items={Array.isArray(item.constraints) ? item.constraints as string[] : []} compact /> + </div> + ))} + </div> + </div> + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Skills</div> + <div className="space-y-2"> + {skills.map((item, index) => ( + <div key={index} className="rounded border p-3 space-y-1.5"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-medium">{String(item.name ?? "")}</span> + <Badge variant="secondary" className="text-[10px]">{String(item.kind ?? "")}</Badge> + </div> + <p className="text-xs text-muted-foreground">{String(item.purpose ?? "")}</p> + <SectionList title="Inputs" items={Array.isArray(item.inputs) ? item.inputs as string[] : []} compact /> + <SectionList title="Outputs" items={Array.isArray(item.outputs) ? item.outputs as string[] : []} compact /> + <SectionList title="Quality Checks" items={Array.isArray(item.qualityChecks) ? item.qualityChecks as string[] : []} compact /> + </div> + ))} + </div> + </div> + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Collaboration</div> + <div className="space-y-2"> + {collaborations.map((item, index) => ( + <div key={index} className="rounded border p-3 space-y-1.5"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-medium">{String(item.partnerRoleId ?? "")}</span> + <Badge variant="outline" className="text-[10px]">{String(item.collaborationType ?? "")}</Badge> + </div> + <p className="text-xs text-muted-foreground">{String(item.trigger ?? "")}</p> + <SectionList title="Payload" items={Array.isArray(item.payload) ? item.payload as string[] : []} compact /> + <SectionList title="Expected Response" items={Array.isArray(item.expectedResponse) ? item.expectedResponse as string[] : []} compact /> + </div> + ))} + </div> + </div> + </div> + ); +} + +export function TaskBoardDisplay({ data }: { data: Record<string, unknown> }) { + const assignments = Array.isArray(data.assignments) ? data.assignments as Array<Record<string, unknown>> : []; + const milestones = Array.isArray(data.milestones) ? data.milestones as string[] : []; + const completionCriteria = Array.isArray(data.completionCriteria) ? data.completionCriteria as string[] : []; + + return ( + <div className="space-y-4"> + <div className="space-y-1"> + <div className="text-sm font-semibold">Research Coordination Task Board</div> + <p className="text-sm leading-relaxed">{String(data.objective ?? "")}</p> + <div className="text-xs text-muted-foreground">Coordinator: {String(data.coordinatorRoleId ?? "")}</div> + </div> + + <SectionList title="Milestones" items={milestones} /> + <SectionList title="Completion Criteria" items={completionCriteria} /> + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Assignments</div> + <div className="space-y-2"> + {assignments.map((assignment, index) => ( + <div key={index} className="rounded border p-3 space-y-1.5"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-medium">{String(assignment.roleName ?? "")}</span> + <Badge variant="outline" className="text-[10px]">{String(assignment.status ?? "")}</Badge> + </div> + <div className="text-xs text-muted-foreground">{String(assignment.workflowSegment ?? "")}</div> + <p className="text-xs">{String(assignment.objective ?? "")}</p> + <SectionList title="Deliverables" items={Array.isArray(assignment.deliverables) ? assignment.deliverables as string[] : []} compact /> + <SectionList title="Dependencies" items={Array.isArray(assignment.dependencies) ? assignment.dependencies as string[] : []} compact /> + </div> + ))} + </div> + </div> + </div> + ); +} + +export function CollaborationPacketDisplay({ data }: { data: Record<string, unknown> }) { + const packet = (data.packet && typeof data.packet === "object" ? data.packet as Record<string, unknown> : null); + const prompts = Array.isArray(data.roleResponseContract) ? data.roleResponseContract as Array<Record<string, unknown>> : []; + const skills = Array.isArray(data.roleSkills) ? data.roleSkills as Array<Record<string, unknown>> : []; + + return ( + <div className="space-y-4"> + <div className="space-y-1"> + <div className="text-sm font-semibold">{String(data.roleName ?? "")} Collaboration Packet</div> + <div className="text-xs text-muted-foreground">{String(data.workflowSegment ?? "")}</div> + </div> + + {packet && ( + <div className="rounded border p-3 space-y-1.5"> + <div className="flex items-center gap-2 flex-wrap"> + <Badge variant="outline" className="text-[10px]">{String(packet.type ?? "")}</Badge> + <span className="text-xs text-muted-foreground"> + {String(packet.fromRoleId ?? "")} -> {String(packet.toRoleId ?? "")} + </span> + </div> + <p className="text-sm">{String(packet.goal ?? "")}</p> + <SectionList title="Payload" items={Array.isArray(packet.payload) ? packet.payload as string[] : []} compact /> + <SectionList title="Expected Response" items={Array.isArray(packet.expectedResponse) ? packet.expectedResponse as string[] : []} compact /> + </div> + )} + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Response Contract</div> + <div className="space-y-2"> + {prompts.map((item, index) => ( + <div key={index} className="rounded border p-3 space-y-1"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-medium">{String(item.title ?? "")}</span> + <Badge variant="outline" className="text-[10px]">{String(item.kind ?? "")}</Badge> + </div> + <p className="text-xs text-muted-foreground">{String(item.objective ?? "")}</p> + </div> + ))} + </div> + </div> + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Available Skills</div> + <div className="space-y-2"> + {skills.map((item, index) => ( + <div key={index} className="rounded border p-3 space-y-1"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-medium">{String(item.name ?? "")}</span> + <Badge variant="secondary" className="text-[10px]">{String(item.kind ?? "")}</Badge> + </div> + <p className="text-xs text-muted-foreground">{String(item.purpose ?? "")}</p> + </div> + ))} + </div> + </div> + </div> + ); +} + +export function ProtocolGraphDisplay({ data }: { data: Record<string, unknown> }) { + const roles = Array.isArray(data.roles) ? data.roles as Array<Record<string, unknown>> : []; + const protocols = Array.isArray(data.protocols) ? data.protocols as Array<Record<string, unknown>> : []; + + return ( + <div className="space-y-4"> + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Roles</div> + <div className="grid gap-2 md:grid-cols-2"> + {roles.map((role, index) => ( + <div key={index} className="rounded border p-3"> + <div className="text-sm font-medium">{String(role.roleName ?? "")}</div> + <div className="text-xs text-muted-foreground">{String(role.workflowSegment ?? "")}</div> + </div> + ))} + </div> + </div> + + <div className="space-y-2"> + <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Communication Protocols</div> + <div className="space-y-2"> + {protocols.map((protocol, index) => ( + <div key={index} className="rounded border p-3 space-y-1.5"> + <div className="text-sm font-medium">{String(protocol.id ?? "")}</div> + <div className="text-xs text-muted-foreground"> + {String(protocol.fromRoleId ?? "")} -> {String(protocol.toRoleId ?? "")} + </div> + <p className="text-xs">{String(protocol.goal ?? "")}</p> + <SectionList title="Required Payload" items={Array.isArray(protocol.requiredPayload) ? protocol.requiredPayload as string[] : []} compact /> + <SectionList title="Response Contract" items={Array.isArray(protocol.responseContract) ? protocol.responseContract as string[] : []} compact /> + </div> + ))} + </div> + </div> + </div> + ); +} + +export function KeyValueDisplay({ data }: { data: Record<string, unknown> }) { + return ( + <div className="space-y-2"> + {Object.entries(data).map(([key, value]) => ( + <div key={key} className="text-sm"> + <span className="font-medium text-muted-foreground capitalize"> + {key.replace(/_/g, " ")}: + </span>{" "} + <span>{typeof value === "string" ? value : JSON.stringify(value)}</span> + </div> + ))} + </div> + ); +} + +export function CheckpointDisplay({ data }: { data: Record<string, unknown> }) { + const title = data.title as string || "Checkpoint"; + const humanSummary = data.humanSummary as string || ""; + const currentFindings = data.currentFindings as string || ""; + const openQuestions = Array.isArray(data.openQuestions) ? data.openQuestions as string[] : []; + const recommended = data.recommendedNextAction as string || ""; + const recommendedWorker = (data.recommendedWorker as Record<string, unknown> | undefined) ?? undefined; + const promptUsed = (data.promptUsed as Record<string, unknown> | undefined) ?? undefined; + const alternatives = Array.isArray(data.alternativeNextActions) ? data.alternativeNextActions as string[] : []; + const stepType = data.stepType as string || ""; + + return ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <span className="font-semibold text-sm">{title}</span> + {stepType && <Badge variant="secondary" className="text-[10px]">{stepType}</Badge>} + </div> + + {humanSummary && <div className="text-sm leading-relaxed">{humanSummary}</div>} + + {currentFindings && ( + <ArtifactNotice title="Findings"> + {currentFindings} + </ArtifactNotice> + )} + + {openQuestions.length > 0 && ( + <div className="text-xs"> + <div className="mb-1 font-medium text-muted-foreground">Open Questions</div> + <ul className="list-disc list-inside text-xs space-y-0.5"> + {openQuestions.map((q, i) => <li key={i}>{q}</li>)} + </ul> + </div> + )} + + {recommended && ( + <ArtifactNotice tone="green"> + <span className="font-medium">Next step: </span> + <span>{recommended}</span> + </ArtifactNotice> + )} + + {recommendedWorker && ( + <ArtifactNotice tone="emerald"> + <span className="font-medium">Next task owner: </span> + <span> + {String(recommendedWorker.roleName ?? "")} ({String(recommendedWorker.nodeType ?? "")}) - {String(recommendedWorker.label ?? "")} + </span> + </ArtifactNotice> + )} + + {promptUsed && ( + <ArtifactNotice tone="slate"> + <span className="font-medium">Prompt used: </span> + <span>{String(promptUsed.title ?? "")}</span> + <div className="mt-1 text-muted-foreground"> + {String(promptUsed.kind ?? "")} - {String(promptUsed.objective ?? "")} + </div> + </ArtifactNotice> + )} + + {alternatives.length > 0 && ( + <div className="text-xs text-muted-foreground"> + <span className="font-medium">Alternatives: </span> + {alternatives.join(" · ")} + </div> + )} + </div> + ); +} + +export function TaskGraphDisplay({ data }: { data: Record<string, unknown> }) { + const nextTask = ( + (data.nextTask as Record<string, unknown> | undefined) + ?? (Array.isArray(data.proposedNodeSpecs) ? data.proposedNodeSpecs[0] as Record<string, unknown> : undefined) + ); + const dispatchPreviews = Array.isArray(data.dispatchPreviews) + ? data.dispatchPreviews as NodeDispatchPreview[] + : []; + const nextTaskCount = typeof data.nextTaskCount === "number" + ? data.nextTaskCount + : typeof data.totalNodes === "number" + ? data.totalNodes + : (nextTask ? 1 : 0); + + return ( + <div className="space-y-3"> + <div className="flex items-center gap-2"> + <Badge variant="outline" className="text-[10px]"> + Next Task + </Badge> + <span className="text-xs text-muted-foreground"> + {nextTaskCount > 0 ? "Single-task dispatch is enabled." : "No task queued."} + </span> + </div> + + {nextTask ? ( + <ArtifactCard className="space-y-2"> + <div className="text-sm font-medium">{String(nextTask.label ?? "Untitled task")}</div> + <div className="flex flex-wrap gap-1.5"> + {typeof nextTask.nodeType === "string" && ( + <Badge variant="secondary" className="text-[10px]"> + {String(nextTask.nodeType)} + </Badge> + )} + {typeof nextTask.assignedRole === "string" && ( + <Badge variant="outline" className="text-[10px]"> + {String(nextTask.assignedRole)} + </Badge> + )} + {typeof nextTask.contextTag === "string" && ( + <Badge variant="outline" className="text-[10px]"> + {String(nextTask.contextTag)} + </Badge> + )} + </div> + {Boolean(nextTask.input) && typeof nextTask.input === "object" && ( + <pre className="overflow-auto rounded bg-muted p-2 text-xs"> + {JSON.stringify(nextTask.input, null, 2)} + </pre> + )} + </ArtifactCard> + ) : ( + <div className="text-xs text-muted-foreground">No next task captured in this artifact.</div> + )} + + {dispatchPreviews.length > 0 && ( + <ArtifactSection title={`Worker Payload Preview (${dispatchPreviews.length})`}> + <div className="space-y-2"> + {dispatchPreviews.map((preview, index) => { + const payload = preview.workerPayload && typeof preview.workerPayload === "object" + ? preview.workerPayload as Record<string, unknown> + : {}; + const deliverables = Array.isArray(preview.deliverables) ? preview.deliverables as string[] : []; + const completionCriteria = Array.isArray(preview.completionCriteria) ? preview.completionCriteria as string[] : []; + const requiredInputKeys = Array.isArray(preview.requiredInputKeys) ? preview.requiredInputKeys as string[] : []; + + return ( + <ArtifactCard key={String(preview.label ?? index)} className="space-y-2"> + <div className="flex flex-wrap items-center gap-2"> + <div className="text-sm font-medium">{String(preview.label ?? `Task ${index + 1}`)}</div> + {typeof preview.nodeType === "string" && ( + <Badge variant="secondary" className="text-[10px]"> + {String(preview.nodeType)} + </Badge> + )} + {typeof preview.assignedRole === "string" && ( + <Badge variant="outline" className="text-[10px]"> + {String(preview.assignedRole)} + </Badge> + )} + </div> + + {typeof preview.templatePurpose === "string" && preview.templatePurpose && ( + <div className="text-xs text-muted-foreground">{String(preview.templatePurpose)}</div> + )} + + {deliverables.length > 0 && ( + <SectionList title="Deliverables" items={deliverables} compact /> + )} + {completionCriteria.length > 0 && ( + <SectionList title="Completion Criteria" items={completionCriteria} compact /> + )} + + {requiredInputKeys.length > 0 && ( + <div className="space-y-1"> + <div className="text-[11px] font-medium text-muted-foreground">Expected payload keys</div> + <div className="flex flex-wrap gap-1.5"> + {requiredInputKeys.map((key) => ( + <Badge key={key} variant="outline" className="text-[10px]"> + {key} + </Badge> + ))} + </div> + </div> + )} + + <pre className="overflow-auto rounded bg-muted p-2 text-xs"> + {JSON.stringify(payload, null, 2)} + </pre> + </ArtifactCard> + ); + })} + </div> + </ArtifactSection> + )} + + {typeof data.suggestedNextContextTag === "string" && data.suggestedNextContextTag && ( + <div className="text-xs text-muted-foreground"> + Context after this task: {data.suggestedNextContextTag} + </div> + )} + </div> + ); +} diff --git a/src/components/deep-research/artifact-viewer.tsx b/src/components/deep-research/artifact-viewer.tsx index 9caa35ce..cbe7cda1 100644 --- a/src/components/deep-research/artifact-viewer.tsx +++ b/src/components/deep-research/artifact-viewer.tsx @@ -1,17 +1,42 @@ "use client"; -import type { - DeepResearchArtifact, - EvidenceRetrievalStatus, - RawExcerpt, - SourceEntry, -} from "@/lib/deep-research/types"; -import type { NodeDispatchPreview } from "@/lib/deep-research/node-spec-templates"; +import type { DeepResearchArtifact } from "@/lib/deep-research/types"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { getNodeDisplayLabel } from "@/lib/deep-research/role-registry"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; +import { + getMarkdownArtifactText, + resolveArtifactRendererKind, +} from "./artifact-renderer-registry"; +import { MarkdownDisplay } from "./artifact-renderer-primitives"; +import { + CheckpointDisplay, + CollaborationPacketDisplay, + KeyValueDisplay, + ProtocolGraphDisplay, + RoleSpecificationDisplay, + TaskBoardDisplay, + TaskGraphDisplay, +} from "./artifact-renderers/workflow-renderers"; +import { + EvidenceCardDisplay, + StructuredSummaryDisplay, +} from "./artifact-renderers/evidence-renderers"; +import { + ExecutionManifestDisplay, + ExecutionPlanDisplay, + MainBrainAuditDisplay, + ReviewAssessmentDisplay, + ReviewerPacketDisplay, + StepResultDisplay, + ValidationPlanDisplay, +} from "./artifact-renderers/analysis-renderers"; +import { + FinalReportMarkdownDisplay, + MemoryIndexDisplay, + MemoryProfileDisplay, + MemorySnapshotDisplay, +} from "./artifact-renderers/memory-renderers"; interface ArtifactViewerProps { artifact: DeepResearchArtifact; @@ -24,8 +49,9 @@ export function ArtifactViewer({ disableScroll = false, evidenceExcerptLimit = 5, }: ArtifactViewerProps) { - const content = artifact.content; - const renderedContent = renderContent(artifact.artifactType, content, { evidenceExcerptLimit }); + const renderedContent = renderArtifactContent(artifact.artifactType, artifact.content, { + evidenceExcerptLimit, + }); return ( <div className="space-y-3"> @@ -50,1282 +76,60 @@ export function ArtifactViewer({ ); } -function renderContent( +function renderArtifactContent( type: string, content: Record<string, unknown>, options?: { evidenceExcerptLimit?: number }, ) { - if (looksLikeTaskBoard(content)) { - return <TaskBoardDisplay data={content} />; - } - - if (looksLikeCollaborationPacket(content)) { - return <CollaborationPacketDisplay data={content} />; - } - - if (looksLikeRoleSpecification(content)) { - return <RoleSpecificationDisplay data={content} />; - } - - if (looksLikeProtocolGraph(content)) { - return <ProtocolGraphDisplay data={content} />; - } - - switch (type) { + switch (resolveArtifactRendererKind(type, content)) { + case "task_board": + return <TaskBoardDisplay data={content} />; + case "collaboration_packet": + return <CollaborationPacketDisplay data={content} />; + case "role_specification": + return <RoleSpecificationDisplay data={content} />; + case "protocol_graph": + return <ProtocolGraphDisplay data={content} />; case "research_brief": return <KeyValueDisplay data={content} />; - case "evidence_card": return <EvidenceCardDisplay data={content} excerptLimit={options?.evidenceExcerptLimit ?? 5} />; - case "structured_summary": - case "literature_round_summary": - return <MarkdownDisplay text={content.summary as string || JSON.stringify(content, null, 2)} />; - + return <StructuredSummaryDisplay data={content} />; case "reviewer_packet": return <ReviewerPacketDisplay data={content} />; - case "review_assessment": return <ReviewAssessmentDisplay data={content} />; - case "main_brain_audit": return <MainBrainAuditDisplay data={content} />; - case "provisional_conclusion": return <KeyValueDisplay data={content} />; - case "validation_plan": return <ValidationPlanDisplay data={content} />; - case "execution_manifest": return <ExecutionManifestDisplay data={content} />; - case "execution_plan": return <ExecutionPlanDisplay data={content} />; - case "step_result": - case "experiment_result": return <StepResultDisplay data={content} />; - case "memory_profile": return <MemoryProfileDisplay data={content} />; - case "memory_snapshot": return <MemorySnapshotDisplay data={content} />; - case "memory_index": return <MemoryIndexDisplay data={content} />; - case "final_report": - return <MarkdownDisplay text={ - content.text as string - || content.report as string - || content.messageToUser as string - || content.content as string - || JSON.stringify(content, null, 2) - } />; - + return <FinalReportMarkdownDisplay content={content} />; case "task_graph": return <TaskGraphDisplay data={content} />; - case "checkpoint": return <CheckpointDisplay data={content} />; - - default: - return <pre className="text-xs bg-muted p-3 rounded overflow-auto">{JSON.stringify(content, null, 2)}</pre>; - } -} - -function looksLikeTaskBoard(content: Record<string, unknown>): boolean { - return typeof content.objective === "string" - && Array.isArray(content.assignments) - && typeof content.coordinatorRoleId === "string"; -} - -function looksLikeCollaborationPacket(content: Record<string, unknown>): boolean { - return typeof content.roleName === "string" - && typeof content.workflowSegment === "string" - && content.packet != null - && typeof content.packet === "object"; -} - -function looksLikeRoleSpecification(content: Record<string, unknown>): boolean { - return typeof content.roleName === "string" - && typeof content.workflowSegment === "string" - && Array.isArray(content.prompts) - && Array.isArray(content.skills); -} - -function looksLikeProtocolGraph(content: Record<string, unknown>): boolean { - return Array.isArray(content.roles) && Array.isArray(content.protocols); -} - -function RoleSpecificationDisplay({ data }: { data: Record<string, unknown> }) { - const prompts = Array.isArray(data.prompts) ? data.prompts as Array<Record<string, unknown>> : []; - const skills = Array.isArray(data.skills) ? data.skills as Array<Record<string, unknown>> : []; - const collaborations = Array.isArray(data.collaborations) ? data.collaborations as Array<Record<string, unknown>> : []; - const responsibilities = Array.isArray(data.coreResponsibilities) ? data.coreResponsibilities as string[] : []; - const standards = Array.isArray(data.performanceStandards) ? data.performanceStandards as string[] : []; - - return ( - <div className="space-y-4"> - <div className="space-y-1"> - <div className="text-sm font-semibold">{String(data.roleName)}</div> - <div className="text-xs text-muted-foreground">{String(data.workflowSegment)}</div> - <p className="text-sm leading-relaxed">{String(data.corePositioning ?? "")}</p> - </div> - - <SectionList title="Core Responsibilities" items={responsibilities} /> - <SectionList title="Performance Standards" items={standards} /> - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Prompts</div> - <div className="space-y-2"> - {prompts.map((item, index) => ( - <div key={index} className="rounded border p-3 space-y-1.5"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="text-sm font-medium">{String(item.title ?? "")}</span> - <Badge variant="outline" className="text-[10px]">{String(item.kind ?? "")}</Badge> - </div> - <p className="text-xs text-muted-foreground">{String(item.objective ?? "")}</p> - <SectionList title="Required Sections" items={Array.isArray(item.requiredSections) ? item.requiredSections as string[] : []} compact /> - <SectionList title="Constraints" items={Array.isArray(item.constraints) ? item.constraints as string[] : []} compact /> - </div> - ))} - </div> - </div> - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Skills</div> - <div className="space-y-2"> - {skills.map((item, index) => ( - <div key={index} className="rounded border p-3 space-y-1.5"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="text-sm font-medium">{String(item.name ?? "")}</span> - <Badge variant="secondary" className="text-[10px]">{String(item.kind ?? "")}</Badge> - </div> - <p className="text-xs text-muted-foreground">{String(item.purpose ?? "")}</p> - <SectionList title="Inputs" items={Array.isArray(item.inputs) ? item.inputs as string[] : []} compact /> - <SectionList title="Outputs" items={Array.isArray(item.outputs) ? item.outputs as string[] : []} compact /> - <SectionList title="Quality Checks" items={Array.isArray(item.qualityChecks) ? item.qualityChecks as string[] : []} compact /> - </div> - ))} - </div> - </div> - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Collaboration</div> - <div className="space-y-2"> - {collaborations.map((item, index) => ( - <div key={index} className="rounded border p-3 space-y-1.5"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="text-sm font-medium">{String(item.partnerRoleId ?? "")}</span> - <Badge variant="outline" className="text-[10px]">{String(item.collaborationType ?? "")}</Badge> - </div> - <p className="text-xs text-muted-foreground">{String(item.trigger ?? "")}</p> - <SectionList title="Payload" items={Array.isArray(item.payload) ? item.payload as string[] : []} compact /> - <SectionList title="Expected Response" items={Array.isArray(item.expectedResponse) ? item.expectedResponse as string[] : []} compact /> - </div> - ))} - </div> - </div> - </div> - ); -} - -function TaskBoardDisplay({ data }: { data: Record<string, unknown> }) { - const assignments = Array.isArray(data.assignments) ? data.assignments as Array<Record<string, unknown>> : []; - const milestones = Array.isArray(data.milestones) ? data.milestones as string[] : []; - const completionCriteria = Array.isArray(data.completionCriteria) ? data.completionCriteria as string[] : []; - - return ( - <div className="space-y-4"> - <div className="space-y-1"> - <div className="text-sm font-semibold">Research Coordination Task Board</div> - <p className="text-sm leading-relaxed">{String(data.objective ?? "")}</p> - <div className="text-xs text-muted-foreground">Coordinator: {String(data.coordinatorRoleId ?? "")}</div> - </div> - - <SectionList title="Milestones" items={milestones} /> - <SectionList title="Completion Criteria" items={completionCriteria} /> - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Assignments</div> - <div className="space-y-2"> - {assignments.map((assignment, index) => ( - <div key={index} className="rounded border p-3 space-y-1.5"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="text-sm font-medium">{String(assignment.roleName ?? "")}</span> - <Badge variant="outline" className="text-[10px]">{String(assignment.status ?? "")}</Badge> - </div> - <div className="text-xs text-muted-foreground">{String(assignment.workflowSegment ?? "")}</div> - <p className="text-xs">{String(assignment.objective ?? "")}</p> - <SectionList title="Deliverables" items={Array.isArray(assignment.deliverables) ? assignment.deliverables as string[] : []} compact /> - <SectionList title="Dependencies" items={Array.isArray(assignment.dependencies) ? assignment.dependencies as string[] : []} compact /> - </div> - ))} - </div> - </div> - </div> - ); -} - -function CollaborationPacketDisplay({ data }: { data: Record<string, unknown> }) { - const packet = (data.packet && typeof data.packet === "object" ? data.packet as Record<string, unknown> : null); - const prompts = Array.isArray(data.roleResponseContract) ? data.roleResponseContract as Array<Record<string, unknown>> : []; - const skills = Array.isArray(data.roleSkills) ? data.roleSkills as Array<Record<string, unknown>> : []; - - return ( - <div className="space-y-4"> - <div className="space-y-1"> - <div className="text-sm font-semibold">{String(data.roleName ?? "")} Collaboration Packet</div> - <div className="text-xs text-muted-foreground">{String(data.workflowSegment ?? "")}</div> - </div> - - {packet && ( - <div className="rounded border p-3 space-y-1.5"> - <div className="flex items-center gap-2 flex-wrap"> - <Badge variant="outline" className="text-[10px]">{String(packet.type ?? "")}</Badge> - <span className="text-xs text-muted-foreground"> - {String(packet.fromRoleId ?? "")} -> {String(packet.toRoleId ?? "")} - </span> - </div> - <p className="text-sm">{String(packet.goal ?? "")}</p> - <SectionList title="Payload" items={Array.isArray(packet.payload) ? packet.payload as string[] : []} compact /> - <SectionList title="Expected Response" items={Array.isArray(packet.expectedResponse) ? packet.expectedResponse as string[] : []} compact /> - </div> - )} - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Response Contract</div> - <div className="space-y-2"> - {prompts.map((item, index) => ( - <div key={index} className="rounded border p-3 space-y-1"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="text-sm font-medium">{String(item.title ?? "")}</span> - <Badge variant="outline" className="text-[10px]">{String(item.kind ?? "")}</Badge> - </div> - <p className="text-xs text-muted-foreground">{String(item.objective ?? "")}</p> - </div> - ))} - </div> - </div> - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Available Skills</div> - <div className="space-y-2"> - {skills.map((item, index) => ( - <div key={index} className="rounded border p-3 space-y-1"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="text-sm font-medium">{String(item.name ?? "")}</span> - <Badge variant="secondary" className="text-[10px]">{String(item.kind ?? "")}</Badge> - </div> - <p className="text-xs text-muted-foreground">{String(item.purpose ?? "")}</p> - </div> - ))} - </div> - </div> - </div> - ); -} - -function ProtocolGraphDisplay({ data }: { data: Record<string, unknown> }) { - const roles = Array.isArray(data.roles) ? data.roles as Array<Record<string, unknown>> : []; - const protocols = Array.isArray(data.protocols) ? data.protocols as Array<Record<string, unknown>> : []; - - return ( - <div className="space-y-4"> - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Roles</div> - <div className="grid gap-2 md:grid-cols-2"> - {roles.map((role, index) => ( - <div key={index} className="rounded border p-3"> - <div className="text-sm font-medium">{String(role.roleName ?? "")}</div> - <div className="text-xs text-muted-foreground">{String(role.workflowSegment ?? "")}</div> - </div> - ))} - </div> - </div> - - <div className="space-y-2"> - <div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Communication Protocols</div> - <div className="space-y-2"> - {protocols.map((protocol, index) => ( - <div key={index} className="rounded border p-3 space-y-1.5"> - <div className="text-sm font-medium">{String(protocol.id ?? "")}</div> - <div className="text-xs text-muted-foreground"> - {String(protocol.fromRoleId ?? "")} -> {String(protocol.toRoleId ?? "")} - </div> - <p className="text-xs">{String(protocol.goal ?? "")}</p> - <SectionList title="Required Payload" items={Array.isArray(protocol.requiredPayload) ? protocol.requiredPayload as string[] : []} compact /> - <SectionList title="Response Contract" items={Array.isArray(protocol.responseContract) ? protocol.responseContract as string[] : []} compact /> - </div> - ))} - </div> - </div> - </div> - ); -} - -function ArtifactSection({ - title, - children, - className = "space-y-2", -}: { - title: string; - children: React.ReactNode; - className?: string; -}) { - return ( - <div className={className}> - <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{title}</div> - {children} - </div> - ); -} - -function ArtifactCard({ - children, - className = "", -}: { - children: React.ReactNode; - className?: string; -}) { - return <div className={`rounded border p-3 ${className}`.trim()}>{children}</div>; -} - -function ArtifactNotice({ - title, - children, - tone = "muted", -}: { - title?: string; - children: React.ReactNode; - tone?: "muted" | "blue" | "green" | "yellow" | "emerald" | "slate"; -}) { - const toneStyles: Record<typeof tone, { container: string; title: string; body: string }> = { - muted: { - container: "bg-muted", - title: "text-muted-foreground", - body: "text-foreground", - }, - blue: { - container: "bg-blue-50 dark:bg-blue-950/50", - title: "text-blue-800 dark:text-blue-200", - body: "text-blue-700 dark:text-blue-300", - }, - green: { - container: "bg-green-50 dark:bg-green-950/50", - title: "text-green-800 dark:text-green-200", - body: "text-green-700 dark:text-green-300", - }, - yellow: { - container: "bg-yellow-50 dark:bg-yellow-950", - title: "text-yellow-800 dark:text-yellow-200", - body: "text-yellow-700 dark:text-yellow-300", - }, - emerald: { - container: "bg-emerald-50 dark:bg-emerald-950/40", - title: "text-emerald-800 dark:text-emerald-200", - body: "text-emerald-700 dark:text-emerald-300", - }, - slate: { - container: "bg-slate-50 dark:bg-slate-900/50", - title: "text-slate-800 dark:text-slate-200", - body: "text-slate-700 dark:text-slate-300", - }, - }; - const styles = toneStyles[tone]; - - return ( - <div className={`rounded p-2 text-xs ${styles.container}`}> - {title ? <div className={`mb-1 font-medium ${styles.title}`}>{title}</div> : null} - <div className={styles.body}>{children}</div> - </div> - ); -} - -function SectionList({ title, items, compact = false }: { title: string; items: string[]; compact?: boolean }) { - if (items.length === 0) { - return null; - } - - return ( - <div> - <div className={`font-medium text-muted-foreground ${compact ? "text-[11px] mb-1" : "text-xs mb-1.5"}`}>{title}</div> - <ul className={`${compact ? "text-[11px]" : "text-xs"} space-y-0.5 list-disc pl-4 text-muted-foreground`}> - {items.map((item, index) => ( - <li key={index}>{item}</li> - ))} - </ul> - </div> - ); -} - -function KeyValueDisplay({ data }: { data: Record<string, unknown> }) { - return ( - <div className="space-y-2"> - {Object.entries(data).map(([key, value]) => ( - <div key={key} className="text-sm"> - <span className="font-medium text-muted-foreground capitalize"> - {key.replace(/_/g, " ")}: - </span>{" "} - <span>{typeof value === "string" ? value : JSON.stringify(value)}</span> - </div> - ))} - </div> - ); -} - -function EvidenceCardDisplay({ - data, - excerptLimit, -}: { - data: Record<string, unknown>; - excerptLimit: number; -}) { - const query = typeof data.query === "string" ? data.query : "Evidence retrieval"; - const retrievalStatus = typeof data.retrievalStatus === "string" ? data.retrievalStatus : "empty"; - const retrievalNotes = typeof data.retrievalNotes === "string" ? data.retrievalNotes : ""; - const coverageSummary = typeof data.coverageSummary === "string" ? data.coverageSummary : ""; - const recoveredFrom = typeof data.recoveredFrom === "string" ? data.recoveredFrom : ""; - const sourcesFound = typeof data.sourcesFound === "number" - ? data.sourcesFound - : typeof data.totalFound === "number" - ? data.totalFound - : 0; - const sourcesAttempted = typeof data.sourcesAttempted === "number" ? data.sourcesAttempted : undefined; - const searchQueries = Array.isArray(data.searchQueries) - ? data.searchQueries.filter((item): item is string => typeof item === "string" && item.trim().length > 0) - : []; - const sources = Array.isArray(data.sources) - ? data.sources.filter(isEvidenceSource) - : []; - const rawExcerpts = Array.isArray(data.rawExcerpts) - ? data.rawExcerpts.filter(isEvidenceExcerpt) - : []; - const normalizedExcerptLimit = Number.isFinite(excerptLimit) ? Math.max(excerptLimit, 0) : rawExcerpts.length; - const visibleExcerpts = rawExcerpts.slice(0, normalizedExcerptLimit); - const hiddenExcerptCount = rawExcerpts.length - visibleExcerpts.length; - - return ( - <div className="space-y-4"> - <ArtifactCard className="space-y-3"> - <div className="space-y-1"> - <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Research Query</div> - <div className="text-sm font-medium leading-relaxed">{query}</div> - </div> - - <div className="flex flex-wrap items-center gap-2"> - <Badge className={evidenceStatusColors[normalizeEvidenceStatus(retrievalStatus)]}> - {formatEvidenceStatus(retrievalStatus)} - </Badge> - <Badge variant="outline" className="text-[10px]"> - {sourcesFound} source{sourcesFound === 1 ? "" : "s"} - {typeof sourcesAttempted === "number" ? ` found / ${sourcesAttempted} attempted` : " found"} - </Badge> - <Badge variant="secondary" className="text-[10px]"> - {rawExcerpts.length} excerpt{rawExcerpts.length === 1 ? "" : "s"} - </Badge> - </div> - - {retrievalNotes ? ( - <ArtifactNotice title="Retrieval Notes" tone={sourcesFound > 0 ? "blue" : "yellow"}> - {retrievalNotes} - </ArtifactNotice> - ) : null} - - {coverageSummary ? ( - <div className="text-xs text-muted-foreground">{coverageSummary}</div> - ) : null} - - {searchQueries.length > 0 ? ( - <div className="space-y-1.5"> - <div className="text-xs font-medium text-muted-foreground">Search terms</div> - <div className="flex flex-wrap gap-1.5"> - {searchQueries.map((searchQuery, index) => ( - <Badge key={`${searchQuery}-${index}`} variant="outline" className="max-w-full text-[10px]"> - <span className="truncate">{searchQuery}</span> - </Badge> - ))} - </div> - </div> - ) : null} - - {recoveredFrom ? ( - <div className="text-[11px] text-muted-foreground"> - Evidence card was reconstructed from {recoveredFrom.replace(/_/g, " ")}. - </div> - ) : null} - </ArtifactCard> - - {sources.length > 0 ? ( - <ArtifactSection title={`Sources (${sources.length})`}> - <div className="space-y-2"> - {sources.map((source, index) => ( - <ArtifactCard key={`${source.title}-${index}`} className="space-y-2"> - <div className="flex items-start justify-between gap-3"> - <div className="min-w-0 space-y-1"> - <div className="text-sm font-medium leading-snug"> - {source.url ? ( - <a - href={source.url} - target="_blank" - rel="noreferrer" - className="underline-offset-2 hover:underline" - > - {source.title} - </a> - ) : ( - source.title - )} - </div> - <div className="text-xs text-muted-foreground"> - {formatSourceMetadata(source)} - </div> - </div> - <Badge variant="outline" className="shrink-0 text-[10px]"> - Source {index + 1} - </Badge> - </div> - - {source.authors && source.authors.length > 0 ? ( - <div className="text-xs text-muted-foreground"> - {truncateList(source.authors, 4)} - </div> - ) : null} - - {source.doi ? ( - <div className="text-[11px] text-muted-foreground"> - DOI: {source.doi} - </div> - ) : null} - </ArtifactCard> - ))} - </div> - </ArtifactSection> - ) : ( - <ArtifactNotice title="No Sources Retrieved" tone="yellow"> - This evidence step did not return any usable sources. - </ArtifactNotice> - )} - - {visibleExcerpts.length > 0 ? ( - <ArtifactSection title={`Key Excerpts (${rawExcerpts.length})`}> - <div className="space-y-2"> - {visibleExcerpts.map((excerpt, index) => { - const source = sources[excerpt.sourceIndex]; - const location = [excerpt.section, excerpt.page].filter(Boolean).join(" - "); - - return ( - <ArtifactCard key={`${excerpt.sourceIndex}-${index}`} className="space-y-2"> - <div className="text-sm leading-relaxed text-foreground"> - {truncateText(excerpt.text, 360)} - </div> - <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground"> - <Badge variant="secondary" className="text-[10px]"> - {source?.title ?? `Source ${excerpt.sourceIndex + 1}`} - </Badge> - {location ? <span>{location}</span> : null} - </div> - </ArtifactCard> - ); - })} - </div> - {hiddenExcerptCount > 0 ? ( - <div className="text-[11px] text-muted-foreground"> - Showing first {visibleExcerpts.length} excerpts. {hiddenExcerptCount} more excerpt{hiddenExcerptCount === 1 ? "" : "s"} remain in the raw artifact. - </div> - ) : null} - </ArtifactSection> - ) : null} - </div> - ); -} - -const evidenceStatusColors: Record<EvidenceRetrievalStatus, string> = { - success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - partial: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", - insufficient_evidence: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200", - failed_retrieval: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", - empty: "bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-200", -}; - -function isEvidenceSource(value: unknown): value is SourceEntry { - return Boolean( - value - && typeof value === "object" - && typeof (value as SourceEntry).title === "string" - && typeof (value as SourceEntry).url === "string" - ); -} - -function isEvidenceExcerpt(value: unknown): value is RawExcerpt { - return Boolean( - value - && typeof value === "object" - && typeof (value as RawExcerpt).text === "string" - && typeof (value as RawExcerpt).sourceIndex === "number" - ); -} - -function formatEvidenceStatus(status: string): string { - switch (status) { - case "success": - return "Retrieved"; - case "partial": - return "Partial"; - case "insufficient_evidence": - return "Limited Evidence"; - case "failed_retrieval": - return "Retrieval Failed"; - case "empty": - return "No Results"; default: - return status; + return ( + <MarkdownDisplay text={typeof getMarkdownArtifactText(content) === "string" + ? getMarkdownArtifactText(content) + : JSON.stringify(content, null, 2)} + /> + ); } } - -function normalizeEvidenceStatus(status: string): EvidenceRetrievalStatus { - switch (status) { - case "success": - case "partial": - case "insufficient_evidence": - case "failed_retrieval": - case "empty": - return status; - default: - return "empty"; - } -} - -function formatSourceMetadata(source: SourceEntry): string { - const parts = [source.year?.toString(), source.venue].filter(Boolean); - return parts.length > 0 ? parts.join(" • ") : "Source metadata unavailable"; -} - -function truncateList(items: string[], maxItems: number): string { - if (items.length <= maxItems) { - return items.join(", "); - } - - return `${items.slice(0, maxItems).join(", ")} +${items.length - maxItems} more`; -} - -function truncateText(text: string, maxLength: number): string { - if (text.length <= maxLength) { - return text; - } - - return `${text.slice(0, maxLength).trimEnd()}...`; -} - -function ReviewerPacketDisplay({ data }: { data: Record<string, unknown> }) { - const verdict = data.verdict as string; - const verdictColors: Record<string, string> = { - approve: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - revise: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", - reject: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", - }; - - return ( - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <Badge className={verdictColors[verdict] || ""}> - {verdict} - </Badge> - <span className="text-xs text-muted-foreground"> - Confidence: {((data.confidence as number) * 100).toFixed(0)}% - </span> - </div> - <div className="text-sm">{data.critique as string}</div> - {Array.isArray(data.suggestions) && data.suggestions.length > 0 && ( - <div> - <div className="text-xs font-medium text-muted-foreground mb-1">Suggestions</div> - <ul className="list-disc list-inside text-sm space-y-0.5"> - {(data.suggestions as string[]).map((s, i) => ( - <li key={i}>{s}</li> - ))} - </ul> - </div> - )} - </div> - ); -} - -function ExecutionPlanDisplay({ data }: { data: Record<string, unknown> }) { - const steps = Array.isArray(data.steps) ? data.steps : []; - return ( - <div className="space-y-2"> - {steps.map((step: Record<string, unknown>, i: number) => ( - <div key={i} className="flex items-start gap-2 text-sm p-2 border rounded"> - <span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded shrink-0"> - {i + 1} - </span> - <div className="flex-1"> - <div className="font-medium">{String(step.label || step.description || "")}</div> - {step.requiresApproval ? ( - <Badge variant="outline" className="text-[10px] mt-1">Needs Approval</Badge> - ) : null} - </div> - </div> - ))} - {steps.length === 0 && ( - <pre className="text-xs bg-muted p-3 rounded overflow-auto">{JSON.stringify(data, null, 2)}</pre> - )} - </div> - ); -} - -function StepResultDisplay({ data }: { data: Record<string, unknown> }) { - const statusColors: Record<string, string> = { - success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - failure: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", - partial: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", - }; - - return ( - <div className="space-y-2"> - <Badge className={statusColors[data.status as string] || ""}> - {data.status as string} - </Badge> - {Array.isArray(data.observations) && ( - <ul className="list-disc list-inside text-sm space-y-0.5"> - {(data.observations as string[]).map((o, i) => ( - <li key={i}>{o}</li> - ))} - </ul> - )} - {data.outputs != null && ( - <pre className="text-xs bg-muted p-2 rounded overflow-auto"> - {JSON.stringify(data.outputs, null, 2)} - </pre> - )} - </div> - ); -} - -function MarkdownDisplay({ text }: { text: string }) { - return ( - <div className="prose prose-sm dark:prose-invert max-w-none"> - <ReactMarkdown remarkPlugins={[remarkGfm]}>{text}</ReactMarkdown> - </div> - ); -} - -function ReviewAssessmentDisplay({ data }: { data: Record<string, unknown> }) { - const verdict = data.combinedVerdict as string; - const reviewerSummary = (data.reviewerSummary as string) || ""; - const reviewHighlights = Array.isArray(data.reviewHighlights) - ? data.reviewHighlights as string[] - : []; - const openIssues = Array.isArray(data.openIssues) - ? data.openIssues as string[] - : []; - const verdictColors: Record<string, string> = { - approve: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - revise: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", - reject: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", - }; - - return ( - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <Badge className={verdictColors[verdict] || ""}> - {verdict} - </Badge> - <span className="text-xs text-muted-foreground"> - Reviewer confidence: {((data.combinedConfidence as number) * 100).toFixed(0)}% - </span> - </div> - - {reviewerSummary && ( - <ArtifactNotice title="Results and Evidence Analyst" tone="blue"> - {reviewerSummary} - </ArtifactNotice> - )} - - {reviewHighlights.length > 0 && ( - <div className="text-xs"> - <div className="mb-1 font-medium text-green-700 dark:text-green-300">Review Highlights</div> - <ul className="list-disc list-inside text-xs space-y-0.5"> - {reviewHighlights.map((item, i) => <li key={i}>{item}</li>)} - </ul> - </div> - )} - - {openIssues.length > 0 && ( - <div> - <div className="text-xs font-medium text-red-700 dark:text-red-300 mb-1">Open Issues</div> - <ul className="list-disc list-inside text-xs space-y-0.5"> - {openIssues.map((item, i) => <li key={i}>{item}</li>)} - </ul> - </div> - )} - - {/* Needs more literature / experiments */} - <div className="flex flex-wrap gap-2"> - {Boolean(data.needsMoreLiterature) && ( - <Badge variant="outline" className="text-[10px] text-amber-600">Needs More Literature</Badge> - )} - {Boolean(data.needsExperimentalValidation) && ( - <Badge variant="outline" className="text-[10px] text-purple-600">Needs Experiments</Badge> - )} - </div> - </div> - ); -} - -function MainBrainAuditDisplay({ data }: { data: Record<string, unknown> }) { - const assessment = data.resultAssessment as string; - const assessmentColors: Record<string, string> = { - good: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", - acceptable: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200", - concerning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200", - problematic: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", - }; - - return ( - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <Badge className={assessmentColors[assessment] || ""}> - {assessment} - </Badge> - {Boolean(data.canProceed) ? ( - <Badge variant="outline" className="text-[10px] text-green-600">Can Proceed</Badge> - ) : ( - <Badge variant="outline" className="text-[10px] text-red-600">Cannot Proceed</Badge> - )} - </div> - - <div className="text-sm">{data.whatWasCompleted as string}</div> - - {Array.isArray(data.issuesAndRisks) && data.issuesAndRisks.length > 0 && ( - <ArtifactNotice title="Issues & Risks" tone="yellow"> - <ul className="list-disc list-inside space-y-0.5"> - {(data.issuesAndRisks as string[]).map((r, i) => <li key={i}>{r}</li>)} - </ul> - </ArtifactNotice> - )} - - {typeof data.continueWillDo === "string" && data.continueWillDo && ( - <ArtifactNotice tone="green"> - <span className="font-medium">Continue will: </span> - <span>{data.continueWillDo}</span> - </ArtifactNotice> - )} - - {Array.isArray(data.alternativeActions) && data.alternativeActions.length > 0 && ( - <div className="text-xs space-y-1"> - <div className="font-medium text-muted-foreground">Alternatives</div> - {(data.alternativeActions as Array<{ label: string; description: string }>).map((alt, i) => ( - <div key={i} className="p-1.5 bg-muted rounded"> - <span className="font-medium">{alt.label}:</span> {alt.description} - </div> - ))} - </div> - )} - </div> - ); -} - -function ValidationPlanDisplay({ data }: { data: Record<string, unknown> }) { - const steps = Array.isArray(data.steps) ? data.steps : []; - return ( - <div className="space-y-3"> - {typeof data.objective === "string" && data.objective && ( - <div className="text-sm"><span className="font-medium">Objective:</span> {data.objective}</div> - )} - {typeof data.hypothesis === "string" && data.hypothesis && ( - <div className="text-sm"><span className="font-medium">Hypothesis:</span> {data.hypothesis}</div> - )} - {steps.length > 0 && ( - <ArtifactSection title="Steps"> - {steps.map((step: Record<string, unknown>, i: number) => ( - <ArtifactCard key={i} className="flex items-start gap-2 p-2 text-xs"> - <span className="font-mono bg-muted px-1.5 py-0.5 rounded shrink-0">{Number(step.stepNumber) || i + 1}</span> - <div className="flex-1"> - <div className="font-medium">{String(step.description || "")}</div> - {typeof step.command === "string" && step.command && <code className="text-[10px] text-muted-foreground">{step.command}</code>} - {Boolean(step.requiresApproval) && <Badge variant="outline" className="text-[10px] mt-1">Needs Approval</Badge>} - </div> - </ArtifactCard> - ))} - </ArtifactSection> - )} - {Array.isArray(data.successCriteria) && data.successCriteria.length > 0 && ( - <div className="text-xs"> - <div className="font-medium text-green-700 dark:text-green-300 mb-1">Success Criteria</div> - <ul className="list-disc list-inside space-y-0.5"> - {(data.successCriteria as string[]).map((c, i) => <li key={i}>{c}</li>)} - </ul> - </div> - )} - </div> - ); -} - -function ExecutionManifestDisplay({ data }: { data: Record<string, unknown> }) { - return ( - <div className="space-y-2"> - <div className="flex items-center gap-2"> - <Badge variant="outline" className="text-[10px]">{String(data.launcherType)}</Badge> - {typeof data.purpose === "string" && data.purpose && <span className="text-xs text-muted-foreground">{data.purpose}</span>} - </div> - <div className="grid grid-cols-3 gap-2 text-xs"> - {data.gpu != null && <div><span className="text-muted-foreground">GPU:</span> {String(data.gpu)}</div>} - {data.memoryMb != null && <div><span className="text-muted-foreground">Memory:</span> {String(data.memoryMb)}MB</div>} - {data.cpu != null && <div><span className="text-muted-foreground">CPU:</span> {String(data.cpu)}</div>} - </div> - {typeof data.command === "string" && data.command && ( - <pre className="text-xs bg-muted p-2 rounded overflow-auto">{data.command}</pre> - )} - {typeof data.chargedGroup === "string" && data.chargedGroup && ( - <div className="text-[10px] text-muted-foreground">Charged to: {data.chargedGroup}</div> - )} - </div> - ); -} - -function MemoryProfileDisplay({ data }: { data: Record<string, unknown> }) { - const activeRequirements = Array.isArray(data.activeRequirements) ? data.activeRequirements as string[] : []; - const activeConstraints = Array.isArray(data.activeConstraints) ? data.activeConstraints as string[] : []; - const openQuestions = Array.isArray(data.openQuestions) ? data.openQuestions as string[] : []; - const activeHypotheses = Array.isArray(data.activeHypotheses) ? data.activeHypotheses as string[] : []; - const keyDecisions = Array.isArray(data.keyDecisions) ? data.keyDecisions as string[] : []; - - return ( - <div className="space-y-3"> - <ArtifactCard className="space-y-2"> - <div className="text-sm font-medium">{String(data.objective ?? "No objective recorded")}</div> - <div className="flex flex-wrap gap-2"> - {typeof data.currentPhase === "string" && ( - <Badge variant="outline" className="text-[10px]"> - {String(data.currentPhase)} - </Badge> - )} - {typeof data.latestCheckpointTitle === "string" && data.latestCheckpointTitle && ( - <Badge variant="secondary" className="text-[10px]"> - {String(data.latestCheckpointTitle)} - </Badge> - )} - </div> - {typeof data.latestRecommendedNextAction === "string" && data.latestRecommendedNextAction && ( - <ArtifactNotice tone="green" title="Latest Next Action"> - {String(data.latestRecommendedNextAction)} - </ArtifactNotice> - )} - {typeof data.latestPlanSummary === "string" && data.latestPlanSummary && ( - <div className="text-xs text-muted-foreground">{String(data.latestPlanSummary)}</div> - )} - </ArtifactCard> - - <SectionList title="Active Requirements" items={activeRequirements} /> - <SectionList title="Active Constraints" items={activeConstraints} /> - <SectionList title="Open Questions" items={openQuestions} /> - <SectionList title="Active Hypotheses" items={activeHypotheses} /> - <SectionList title="Key Decisions" items={keyDecisions} /> - </div> - ); -} - -function MemorySnapshotDisplay({ data }: { data: Record<string, unknown> }) { - const acceptedFacts = Array.isArray(data.acceptedFacts) ? data.acceptedFacts as string[] : []; - const contestedFacts = Array.isArray(data.contestedFacts) ? data.contestedFacts as string[] : []; - const unresolvedGaps = Array.isArray(data.unresolvedGaps) ? data.unresolvedGaps as string[] : []; - const focusAreas = Array.isArray(data.focusAreas) ? data.focusAreas as string[] : []; - - return ( - <div className="space-y-3"> - <ArtifactCard className="space-y-2"> - <div className="text-sm font-medium">{String(data.title ?? "Memory Snapshot")}</div> - {typeof data.summary === "string" && data.summary && ( - <div className="text-sm leading-relaxed">{String(data.summary)}</div> - )} - {typeof data.nextStep === "string" && data.nextStep && ( - <ArtifactNotice title="Next Step" tone="blue"> - {String(data.nextStep)} - </ArtifactNotice> - )} - {focusAreas.length > 0 && ( - <div className="flex flex-wrap gap-1.5"> - {focusAreas.map((focusArea, index) => ( - <Badge key={`${focusArea}-${index}`} variant="outline" className="text-[10px]"> - {focusArea} - </Badge> - ))} - </div> - )} - </ArtifactCard> - - <SectionList title="Accepted Facts" items={acceptedFacts} /> - <SectionList title="Contested Facts" items={contestedFacts} /> - <SectionList title="Unresolved Gaps" items={unresolvedGaps} /> - </div> - ); -} - -function MemoryIndexDisplay({ data }: { data: Record<string, unknown> }) { - const items = Array.isArray(data.items) ? data.items as Array<Record<string, unknown>> : []; - const sourceOfTruth = typeof data.sourceOfTruth === "string" ? data.sourceOfTruth : ""; - - return ( - <div className="space-y-3"> - <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground"> - {typeof data.itemCount === "number" && ( - <Badge variant="outline" className="text-[10px]"> - {Number(data.itemCount)} item(s) - </Badge> - )} - {Boolean(data.stats && typeof data.stats === "object") && ( - <span>{JSON.stringify(data.stats)}</span> - )} - {sourceOfTruth && ( - <span>source-of-truth: {sourceOfTruth}</span> - )} - </div> - - <div className="space-y-2"> - {items.map((item, index) => ( - <ArtifactCard key={String(item.id ?? index)} className="space-y-2"> - <div className="flex flex-wrap items-center gap-2"> - <div className="text-sm font-medium">{String(item.title ?? "Untitled memory")}</div> - {typeof item.kind === "string" && ( - <Badge variant="secondary" className="text-[10px]"> - {String(item.kind)} - </Badge> - )} - {typeof item.category === "string" && ( - <Badge variant="outline" className="text-[10px]"> - {String(item.category)} - </Badge> - )} - </div> - <div className="text-xs text-muted-foreground leading-relaxed"> - {String(item.summary ?? "")} - </div> - {Array.isArray(item.anchors) && (item.anchors as Array<Record<string, unknown>>).length > 0 && ( - <div className="text-[11px] text-muted-foreground"> - Anchor: {(item.anchors as Array<Record<string, unknown>>).slice(0, 2).map((anchor) => { - const parts: string[] = []; - if (typeof anchor.artifactType === "string") parts.push(String(anchor.artifactType)); - if (typeof anchor.artifactId === "string") parts.push(String(anchor.artifactId)); - if (typeof anchor.messageId === "string") parts.push(`message:${String(anchor.messageId)}`); - if (typeof anchor.sourceIndex === "number") parts.push(`source#${Number(anchor.sourceIndex) + 1}`); - if (typeof anchor.excerptIndex === "number") parts.push(`excerpt#${Number(anchor.excerptIndex) + 1}`); - if (typeof anchor.claimId === "string") parts.push(`claim:${String(anchor.claimId)}`); - if (typeof anchor.gapIndex === "number") parts.push(`gap#${Number(anchor.gapIndex) + 1}`); - if (typeof anchor.field === "string") parts.push(String(anchor.field)); - return parts.join(" / "); - }).filter(Boolean).join(" | ")} - </div> - )} - <div className="flex flex-wrap gap-1.5"> - {Array.isArray(item.tags) && (item.tags as string[]).slice(0, 6).map((tag, tagIndex) => ( - <Badge key={`${tag}-${tagIndex}`} variant="outline" className="text-[10px]"> - {tag} - </Badge> - ))} - </div> - </ArtifactCard> - ))} - </div> - </div> - ); -} - -function CheckpointDisplay({ data }: { data: Record<string, unknown> }) { - const title = data.title as string || "Checkpoint"; - const humanSummary = data.humanSummary as string || ""; - const currentFindings = data.currentFindings as string || ""; - const openQuestions = Array.isArray(data.openQuestions) ? data.openQuestions as string[] : []; - const recommended = data.recommendedNextAction as string || ""; - const recommendedWorker = (data.recommendedWorker as Record<string, unknown> | undefined) ?? undefined; - const promptUsed = (data.promptUsed as Record<string, unknown> | undefined) ?? undefined; - const alternatives = Array.isArray(data.alternativeNextActions) ? data.alternativeNextActions as string[] : []; - const stepType = data.stepType as string || ""; - - return ( - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <span className="font-semibold text-sm">{title}</span> - {stepType && <Badge variant="secondary" className="text-[10px]">{stepType}</Badge>} - </div> - - {humanSummary && <div className="text-sm leading-relaxed">{humanSummary}</div>} - - {currentFindings && ( - <ArtifactNotice title="Findings"> - {currentFindings} - </ArtifactNotice> - )} - - {openQuestions.length > 0 && ( - <div className="text-xs"> - <div className="mb-1 font-medium text-muted-foreground">Open Questions</div> - <ul className="list-disc list-inside text-xs space-y-0.5"> - {openQuestions.map((q, i) => <li key={i}>{q}</li>)} - </ul> - </div> - )} - - {recommended && ( - <ArtifactNotice tone="green"> - <span className="font-medium">Next step: </span> - <span>{recommended}</span> - </ArtifactNotice> - )} - - {recommendedWorker && ( - <ArtifactNotice tone="emerald"> - <span className="font-medium">Next task owner: </span> - <span> - {String(recommendedWorker.roleName ?? "")} ({String(recommendedWorker.nodeType ?? "")}) - {String(recommendedWorker.label ?? "")} - </span> - </ArtifactNotice> - )} - - {promptUsed && ( - <ArtifactNotice tone="slate"> - <span className="font-medium">Prompt used: </span> - <span>{String(promptUsed.title ?? "")}</span> - <div className="mt-1 text-muted-foreground"> - {String(promptUsed.kind ?? "")} - {String(promptUsed.objective ?? "")} - </div> - </ArtifactNotice> - )} - - {alternatives.length > 0 && ( - <div className="text-xs text-muted-foreground"> - <span className="font-medium">Alternatives: </span> - {alternatives.join(" · ")} - </div> - )} - </div> - ); -} - -function TaskGraphDisplay({ data }: { data: Record<string, unknown> }) { - const nextTask = ( - (data.nextTask as Record<string, unknown> | undefined) - ?? (Array.isArray(data.proposedNodeSpecs) ? data.proposedNodeSpecs[0] as Record<string, unknown> : undefined) - ); - const dispatchPreviews = Array.isArray(data.dispatchPreviews) - ? data.dispatchPreviews as NodeDispatchPreview[] - : []; - const nextTaskCount = typeof data.nextTaskCount === "number" - ? data.nextTaskCount - : typeof data.totalNodes === "number" - ? data.totalNodes - : (nextTask ? 1 : 0); - - return ( - <div className="space-y-3"> - <div className="flex items-center gap-2"> - <Badge variant="outline" className="text-[10px]"> - Next Task - </Badge> - <span className="text-xs text-muted-foreground"> - {nextTaskCount > 0 ? "Single-task dispatch is enabled." : "No task queued."} - </span> - </div> - - {nextTask ? ( - <ArtifactCard className="space-y-2"> - <div className="text-sm font-medium">{String(nextTask.label ?? "Untitled task")}</div> - <div className="flex flex-wrap gap-1.5"> - {typeof nextTask.nodeType === "string" && ( - <Badge variant="secondary" className="text-[10px]"> - {String(nextTask.nodeType)} - </Badge> - )} - {typeof nextTask.assignedRole === "string" && ( - <Badge variant="outline" className="text-[10px]"> - {String(nextTask.assignedRole)} - </Badge> - )} - {typeof nextTask.contextTag === "string" && ( - <Badge variant="outline" className="text-[10px]"> - {String(nextTask.contextTag)} - </Badge> - )} - </div> - {Boolean(nextTask.input) && typeof nextTask.input === "object" && ( - <pre className="overflow-auto rounded bg-muted p-2 text-xs"> - {JSON.stringify(nextTask.input, null, 2)} - </pre> - )} - </ArtifactCard> - ) : ( - <div className="text-xs text-muted-foreground">No next task captured in this artifact.</div> - )} - - {dispatchPreviews.length > 0 && ( - <ArtifactSection title={`Worker Payload Preview (${dispatchPreviews.length})`}> - <div className="space-y-2"> - {dispatchPreviews.map((preview, index) => { - const payload = preview.workerPayload && typeof preview.workerPayload === "object" - ? preview.workerPayload as Record<string, unknown> - : {}; - const deliverables = Array.isArray(preview.deliverables) ? preview.deliverables as string[] : []; - const completionCriteria = Array.isArray(preview.completionCriteria) ? preview.completionCriteria as string[] : []; - const requiredInputKeys = Array.isArray(preview.requiredInputKeys) ? preview.requiredInputKeys as string[] : []; - - return ( - <ArtifactCard key={String(preview.label ?? index)} className="space-y-2"> - <div className="flex flex-wrap items-center gap-2"> - <div className="text-sm font-medium">{String(preview.label ?? `Task ${index + 1}`)}</div> - {typeof preview.nodeType === "string" && ( - <Badge variant="secondary" className="text-[10px]"> - {String(preview.nodeType)} - </Badge> - )} - {typeof preview.assignedRole === "string" && ( - <Badge variant="outline" className="text-[10px]"> - {String(preview.assignedRole)} - </Badge> - )} - </div> - - {typeof preview.templatePurpose === "string" && preview.templatePurpose && ( - <div className="text-xs text-muted-foreground">{String(preview.templatePurpose)}</div> - )} - - {deliverables.length > 0 && ( - <SectionList title="Deliverables" items={deliverables} compact /> - )} - {completionCriteria.length > 0 && ( - <SectionList title="Completion Criteria" items={completionCriteria} compact /> - )} - - {requiredInputKeys.length > 0 && ( - <div className="space-y-1"> - <div className="text-[11px] font-medium text-muted-foreground">Expected payload keys</div> - <div className="flex flex-wrap gap-1.5"> - {requiredInputKeys.map((key) => ( - <Badge key={key} variant="outline" className="text-[10px]"> - {key} - </Badge> - ))} - </div> - </div> - )} - - <pre className="overflow-auto rounded bg-muted p-2 text-xs"> - {JSON.stringify(payload, null, 2)} - </pre> - </ArtifactCard> - ); - })} - </div> - </ArtifactSection> - )} - - {typeof data.suggestedNextContextTag === "string" && data.suggestedNextContextTag && ( - <div className="text-xs text-muted-foreground"> - Context after this task: {data.suggestedNextContextTag} - </div> - )} - </div> - ); -} diff --git a/src/components/deep-research/checkpoint-review.tsx b/src/components/deep-research/checkpoint-review.tsx index c3583335..317bdfdd 100644 --- a/src/components/deep-research/checkpoint-review.tsx +++ b/src/components/deep-research/checkpoint-review.tsx @@ -112,7 +112,8 @@ export function CheckpointReview({ checkpoint, artifacts, onConfirm }: Checkpoin ); const taskGraphArtifacts = relatedArtifacts.filter((artifact) => artifact.artifactType === "task_graph"); const evidenceArtifacts = relatedArtifacts.filter((artifact) => artifact.artifactType === "evidence_card"); - const otherArtifacts = relatedArtifacts.filter((artifact) => !["evidence_card", "task_graph"].includes(artifact.artifactType)); + const finalReportArtifacts = relatedArtifacts.filter((artifact) => artifact.artifactType === "final_report"); + const otherArtifacts = relatedArtifacts.filter((artifact) => !["evidence_card", "task_graph", "final_report"].includes(artifact.artifactType)); const handleAction = async (outcome: ConfirmationOutcome) => { setSubmitting(true); @@ -330,6 +331,21 @@ export function CheckpointReview({ checkpoint, artifacts, onConfirm }: Checkpoin </div> )} + {finalReportArtifacts.length > 0 && ( + <div className="space-y-3"> + <div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> + Final Report Draft + </div> + <div className="space-y-3"> + {finalReportArtifacts.map((artifact) => ( + <div key={artifact.id} className="rounded-lg border bg-background p-3"> + <ArtifactViewer artifact={artifact} disableScroll /> + </div> + ))} + </div> + </div> + )} + {/* Related artifacts */} {otherArtifacts.length > 0 && ( <div className="flex flex-wrap gap-1"> diff --git a/src/components/deep-research/deep-research-panel.tsx b/src/components/deep-research/deep-research-panel.tsx index fa6eda9f..abf6a371 100644 --- a/src/components/deep-research/deep-research-panel.tsx +++ b/src/components/deep-research/deep-research-panel.tsx @@ -72,13 +72,21 @@ export function DeepResearchPanel({ const { sessions, mutate: mutateSessions } = useDeepResearchSessions(workspaceId); const { session, mutate: mutateSession } = useDeepResearchSession(activeSessionId ?? undefined); const { messages, mutate: mutateMessages } = useDeepResearchMessages(activeSessionId ?? undefined); - const { nodes } = useDeepResearchNodes(activeSessionId ?? undefined); - const { artifacts } = useDeepResearchArtifacts(activeSessionId ?? undefined); + const { nodes, mutate: mutateNodes } = useDeepResearchNodes(activeSessionId ?? undefined); + const { artifacts, mutate: mutateArtifacts } = useDeepResearchArtifacts(activeSessionId ?? undefined); const { events } = useDeepResearchEvents(activeSessionId ?? undefined); const activeSessionPath = activeSessionId ? `/api/deep-research/sessions/${activeSessionId}` : null; const selectedNode = nodes.find((n) => n.id === selectedNodeId) ?? null; const isInterfaceOnly = session?.config.interfaceOnly === true; + const refreshActiveSessionResources = useCallback(async () => { + await Promise.all([ + mutateSession(), + mutateMessages(), + mutateNodes(), + mutateArtifacts(), + ]); + }, [mutateArtifacts, mutateMessages, mutateNodes, mutateSession]); const resetSessionChrome = useCallback(() => { setSelectedNodeId(null); setDrawerOpen(false); @@ -185,10 +193,10 @@ export function DeepResearchPanel({ "Failed to send message", ); if (response) { - await Promise.all([mutateMessages(), mutateSession()]); + await refreshActiveSessionResources(); } }, - [mutateMessages, mutateSession, runSessionRequest], + [refreshActiveSessionResources, runSessionRequest], ); const handleApprove = useCallback( @@ -203,13 +211,13 @@ export function DeepResearchPanel({ "Failed to process approval", ); if (response) { - await mutateSession(); + await refreshActiveSessionResources(); if (response.message) { toast(response.message); } } }, - [mutateSession, runSessionRequest], + [refreshActiveSessionResources, runSessionRequest], ); const handleConfirm = useCallback( @@ -224,7 +232,16 @@ export function DeepResearchPanel({ "Failed to process confirmation", ); if (response) { - await mutateSession(); + await refreshActiveSessionResources(); + + window.setTimeout(() => { + void refreshActiveSessionResources(); + }, 800); + + window.setTimeout(() => { + void refreshActiveSessionResources(); + }, 2200); + if (response.message) { toast(response.message); return; @@ -238,7 +255,7 @@ export function DeepResearchPanel({ ); } }, - [mutateSession, runSessionRequest], + [refreshActiveSessionResources, runSessionRequest], ); const handleStartRun = useCallback(async () => { @@ -248,14 +265,14 @@ export function DeepResearchPanel({ "Failed to start research", ); if (response) { - await mutateSession(); + await refreshActiveSessionResources(); if (response.disabled) { toast(response.message ?? "Deep Research is running in interface-only mode."); return; } toast.success("Research started"); } - }, [mutateSession, runSessionRequest]); + }, [refreshActiveSessionResources, runSessionRequest]); const handleNodeSelect = useCallback((nodeId: string) => { setSelectedNodeId(nodeId); @@ -434,6 +451,7 @@ export function DeepResearchPanel({ artifacts={artifacts} selectedNode={selectedNode} resolvedModel={session.config.resolvedModel ?? null} + modelOverrides={session.config.modelOverrides ?? null} onSelectRoleNode={handleRoleNodeSelect} onSendMessage={handleSendMessage} /> diff --git a/src/components/deep-research/final-report-view.tsx b/src/components/deep-research/final-report-view.tsx index df549490..7d58a602 100644 --- a/src/components/deep-research/final-report-view.tsx +++ b/src/components/deep-research/final-report-view.tsx @@ -17,8 +17,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { DeepResearchSession, DeepResearchArtifact } from "@/lib/deep-research/types"; import { - extractFinalReportText, getLatestFinalReportArtifact, + resolveFinalReportPresentation, } from "@/lib/deep-research/final-report"; interface FinalReportViewProps { @@ -32,7 +32,7 @@ export function FinalReportView({ session, artifacts }: FinalReportViewProps) { const [copied, setCopied] = useState(false); const finalReport = getLatestFinalReportArtifact(artifacts); - const reportText = finalReport ? extractFinalReportText(finalReport) : ""; + const { reportText, citationCoverage } = resolveFinalReportPresentation(finalReport, artifacts); const handleSaveToWorkspace = async () => { setSaving(true); @@ -138,6 +138,33 @@ export function FinalReportView({ session, artifacts }: FinalReportViewProps) { )} </div> + {citationCoverage && ( + <div className="px-4 py-2 border-b border-border/50 bg-blue-50/40 dark:bg-blue-950/20"> + <div className="flex flex-wrap items-center gap-2 text-[11px]"> + <Badge variant="outline" className="text-[10px]"> + Citations {citationCoverage.citedCitationCount}/{citationCoverage.availableCitationCount} + </Badge> + <Badge + variant="outline" + className={citationCoverage.meetsCoverage ? "text-green-600 border-green-300" : "text-amber-600 border-amber-300"} + > + Target {citationCoverage.minimumRequiredCitationCount} + </Badge> + <Badge + variant="outline" + className={citationCoverage.hasReferencesSection ? "text-green-600 border-green-300" : "text-red-600 border-red-300"} + > + {citationCoverage.hasReferencesSection ? "References present" : "References missing"} + </Badge> + {citationCoverage.revisedForCoverage && ( + <Badge variant="secondary" className="text-[10px]"> + Coverage revised + </Badge> + )} + </div> + </div> + )} + {/* Report content — full height, scrollable */} <ScrollArea className="flex-1 min-h-0"> <div className="px-6 py-5 max-w-none"> diff --git a/src/components/deep-research/research-chat.tsx b/src/components/deep-research/research-chat.tsx index ef98939b..2474ec4b 100644 --- a/src/components/deep-research/research-chat.tsx +++ b/src/components/deep-research/research-chat.tsx @@ -16,7 +16,10 @@ import { isTerminalSessionStatus, } from "@/lib/deep-research/session-status"; import { getNodeDisplayLabel, getStructuredRoleDisplayName, RESEARCHER_ROLE_ID } from "@/lib/deep-research/role-registry"; -import { getLatestFinalReportArtifact } from "@/lib/deep-research/final-report"; +import { + getLatestFinalReportArtifact, + resolveFinalReportPresentation, +} from "@/lib/deep-research/final-report"; import type { DeepResearchMessage, DeepResearchSession, @@ -67,6 +70,7 @@ export const ResearchChat = memo(function ResearchChat({ // Get the final report artifact (for completed sessions) const finalReportArtifact = getLatestFinalReportArtifact(artifacts); + const { citationCoverage: finalReportCitationCoverage } = resolveFinalReportPresentation(finalReportArtifact, artifacts); const visibleMessages = messages.filter((message) => !isNodeDetailOnlyMessage(message)); useEffect(() => { @@ -254,9 +258,20 @@ export const ResearchChat = memo(function ResearchChat({ <div className="space-y-3"> <div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-950/50 rounded-lg border border-green-200 dark:border-green-800"> <CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 shrink-0" /> - <span className="text-sm font-medium text-green-800 dark:text-green-200"> - Research completed - </span> + <div className="min-w-0"> + <span className="text-sm font-medium text-green-800 dark:text-green-200"> + Research completed + </span> + {finalReportCitationCoverage && ( + <div className="mt-1 text-[11px] text-green-700 dark:text-green-300"> + Citations {finalReportCitationCoverage.citedCitationCount}/{finalReportCitationCoverage.availableCitationCount} + {" · "} + target {finalReportCitationCoverage.minimumRequiredCitationCount} + {" · "} + {finalReportCitationCoverage.hasReferencesSection ? "references present" : "references missing"} + </div> + )} + </div> </div> {finalReportArtifact ? ( diff --git a/src/components/deep-research/role-studio-panel.tsx b/src/components/deep-research/role-studio-panel.tsx index 26d00886..68d2f065 100644 --- a/src/components/deep-research/role-studio-panel.tsx +++ b/src/components/deep-research/role-studio-panel.tsx @@ -22,6 +22,7 @@ interface RoleStudioPanelProps { artifacts: DeepResearchArtifact[]; selectedNode: DeepResearchNode | null; resolvedModel?: { provider: string; modelId: string } | null; + modelOverrides?: Partial<Record<ModelRole, { provider: string; modelId: string }>> | null; onSelectRoleNode?: (nodeId: string) => void; onSendMessage: ( content: string, @@ -61,6 +62,7 @@ export function RoleStudioPanel({ artifacts, selectedNode, resolvedModel, + modelOverrides, onSelectRoleNode, onSendMessage, }: RoleStudioPanelProps) { @@ -84,9 +86,12 @@ export function RoleStudioPanel({ .find((node) => node.assignedRole === activeRoleId && typeof node.assignedModel === "string" && node.assignedModel.length > 0) ?? null, [activeRoleId, nodes], ); - const activeRoleModel = resolvedModel - ? `${resolvedModel.provider}/${resolvedModel.modelId}` - : latestRoleRuntimeNode?.assignedModel ?? null; + const activeRoleOverride = modelOverrides?.[activeRoleId]; + const activeRoleModel = activeRoleOverride + ? `${activeRoleOverride.provider}/${activeRoleOverride.modelId}` + : resolvedModel + ? `${resolvedModel.provider}/${resolvedModel.modelId}` + : latestRoleRuntimeNode?.assignedModel ?? null; if (!activeRole) { return ( @@ -185,7 +190,7 @@ export function RoleStudioPanel({ </Badge> {activeRoleModel && ( <Badge variant="outline" className="text-[10px] font-mono"> - Model: {activeRoleModel} + Model: {activeRoleModel}{activeRoleOverride ? " (Override)" : ""} </Badge> )} </div> diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 1802d429..9fb7e3a9 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -7,6 +7,7 @@ import { Bot, Settings, Zap, FolderOpen, Minimize2, Database } from "lucide-reac import { Button } from "@/components/ui/button"; import { ThemeToggle } from "./theme-toggle"; import { LanguageToggle } from "./language-toggle"; +import { UserMenu } from "./user-menu"; import { Tooltip, TooltipContent, @@ -86,6 +87,7 @@ export function Header({ onToggleMinimalMode, showMinimalToggle }: HeaderProps) <LanguageToggle /> <ThemeToggle /> + <UserMenu /> <div className="mx-1 h-5 w-px bg-border/50" /> diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx new file mode 100644 index 00000000..a5d4a7d4 --- /dev/null +++ b/src/components/layout/user-menu.tsx @@ -0,0 +1,61 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Shield, LogOut, UserCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAuthUser } from "@/lib/hooks/use-auth"; + +export function UserMenu() { + const router = useRouter(); + const { user } = useAuthUser(); + + async function logout() { + await fetch("/api/auth/logout", { method: "POST", credentials: "include" }); + router.replace("/login"); + router.refresh(); + } + + if (!user) { + return null; + } + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon" className="h-9 w-9 rounded-lg"> + <UserCircle className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuLabel> + <span className="block truncate">{user.name}</span> + <span className="block truncate text-xs font-normal text-muted-foreground"> + {user.email} + </span> + </DropdownMenuLabel> + <DropdownMenuSeparator /> + {user.role === "admin" && ( + <DropdownMenuItem asChild> + <Link href="/admin/users"> + <Shield className="mr-2 h-4 w-4" /> + User management + </Link> + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={logout}> + <LogOut className="mr-2 h-4 w-4" /> + Sign out + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); +} diff --git a/src/components/skills/skill-autocomplete.tsx b/src/components/skills/skill-autocomplete.tsx index 628b5575..03bad1e5 100644 --- a/src/components/skills/skill-autocomplete.tsx +++ b/src/components/skills/skill-autocomplete.tsx @@ -1,15 +1,24 @@ "use client"; -import React, { useEffect, useState, useRef } from "react"; +import React, { useEffect, useState, useRef, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Zap } from "lucide-react"; +import { Zap, Terminal } from "lucide-react"; import type { Skill } from "@/types"; -import { getMatchingSkillsForSlashQuery } from "@/components/agent/slash-command"; +import { + getMatchingSkillsForSlashQuery, + getMatchingBuiltinCommands, + type BuiltinCommand, +} from "@/components/agent/slash-command"; + +type AutocompleteItem = + | { kind: "skill"; skill: Skill } + | { kind: "builtin"; command: BuiltinCommand }; interface SkillAutocompleteProps { query: string; skills: Skill[]; onSelect: (skill: Skill) => void; + onBuiltinSelect?: (command: BuiltinCommand) => void; onClose: () => void; } @@ -17,6 +26,7 @@ export function SkillAutocomplete({ query, skills, onSelect, + onBuiltinSelect, onClose, }: SkillAutocompleteProps) { const t = useTranslations("skills"); @@ -24,13 +34,25 @@ export function SkillAutocomplete({ const listRef = useRef<HTMLDivElement>(null); const [trackedQuery, setTrackedQuery] = useState(query); - const filtered = getMatchingSkillsForSlashQuery(skills, query); + const filteredSkills = getMatchingSkillsForSlashQuery(skills, query); + const filteredBuiltins = getMatchingBuiltinCommands(query); + + const items: AutocompleteItem[] = useMemo(() => [ + ...filteredBuiltins.map((command) => ({ kind: "builtin" as const, command })), + ...filteredSkills.map((skill) => ({ kind: "skill" as const, skill })), + ], [filteredSkills, filteredBuiltins]); if (trackedQuery !== query) { setTrackedQuery(query); setSelectedIndex(0); } + // Clamp selectedIndex when items list shrinks (render-time derivation) + const clampedIndex = Math.min(selectedIndex, Math.max(0, items.length - 1)); + if (clampedIndex !== selectedIndex) { + setSelectedIndex(clampedIndex); + } + // Scroll selected item into view useEffect(() => { if (listRef.current) { @@ -43,13 +65,19 @@ export function SkillAutocomplete({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1)); + setSelectedIndex((i) => Math.min(i + 1, items.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSelectedIndex((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter" && filtered.length > 0) { + } else if (e.key === "Enter" && items.length > 0) { e.preventDefault(); - onSelect(filtered[selectedIndex]); + const item = items[selectedIndex]; + if (!item) return; + if (item.kind === "skill") { + onSelect(item.skill); + } else if (item.kind === "builtin" && onBuiltinSelect) { + onBuiltinSelect(item.command); + } } else if (e.key === "Tab") { onClose(); } else if (e.key === "Escape") { @@ -60,9 +88,9 @@ export function SkillAutocomplete({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [filtered, selectedIndex, onSelect, onClose]); + }, [items, selectedIndex, onSelect, onBuiltinSelect, onClose]); - if (filtered.length === 0) { + if (items.length === 0) { return ( <div className="absolute bottom-full left-0 right-0 mb-1 rounded-md border border-[#30363d] bg-[#161b22] p-2 text-xs text-[#565f89] z-50"> {t("noMatchingSkills")} @@ -75,26 +103,46 @@ export function SkillAutocomplete({ ref={listRef} className="absolute bottom-full left-0 right-0 mb-1 rounded-md border border-[#30363d] bg-[#161b22] overflow-hidden z-50 max-h-[200px] overflow-y-auto" > - {filtered.map((skill, index) => ( - <button - key={skill.id} - onClick={() => onSelect(skill)} - className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${ - index === selectedIndex - ? "bg-[#1c2129] text-[#c9d1d9]" - : "text-[#8b949e] hover:bg-[#1c2129]" - }`} - > - <Zap className="h-3 w-3 shrink-0 text-[#7aa2f7]" /> - <span className="font-mono text-[#7aa2f7]">/{skill.slug}</span> - <span className="truncate">{skill.name}</span> - {skill.description && ( - <span className="text-[#565f89] truncate ml-auto text-[10px]"> - {skill.description} + {items.map((item, index) => { + const isBuiltin = item.kind === "builtin"; + const slug = isBuiltin ? item.command.slug : item.skill.slug; + const name = isBuiltin ? item.command.name : item.skill.name; + const description = isBuiltin ? item.command.description : item.skill.description; + const key = isBuiltin ? `builtin-${slug}` : item.skill.id; + + return ( + <button + key={key} + onClick={() => { + if (isBuiltin && onBuiltinSelect) { + onBuiltinSelect(item.command); + } else if (!isBuiltin) { + onSelect(item.skill); + } + }} + className={`flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${ + index === selectedIndex + ? "bg-[#1c2129] text-[#c9d1d9]" + : "text-[#8b949e] hover:bg-[#1c2129]" + }`} + > + {isBuiltin ? ( + <Terminal className="h-3 w-3 shrink-0 text-[#9ece6a]" /> + ) : ( + <Zap className="h-3 w-3 shrink-0 text-[#7aa2f7]" /> + )} + <span className={`font-mono ${isBuiltin ? "text-[#9ece6a]" : "text-[#7aa2f7]"}`}> + /{slug} </span> - )} - </button> - ))} + <span className="truncate">{name}</span> + {description && ( + <span className="text-[#565f89] truncate ml-auto text-[10px]"> + {description} + </span> + )} + </button> + ); + })} </div> ); } diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 38da44e8..8a0db444 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -79,12 +79,12 @@ export async function register() { } // Ensure .env.local exists (generate from .env.example if missing) - const { ensureEnvLocal } = await import("@/lib/env-file"); - ensureEnvLocal(); + const { ensureEnvLocalForStartup } = await import("@/lib/env-file"); + ensureEnvLocalForStartup(); // Run database migrations const { runMigrations } = await import("@/lib/db/migrate"); - runMigrations(); + await runMigrations(); // Start the Feishu WebSocket client (if configured) const { startFeishuWSClient } = await import( diff --git a/src/lib/agent/buddy/companion.ts b/src/lib/agent/buddy/companion.ts new file mode 100644 index 00000000..eb41f448 --- /dev/null +++ b/src/lib/agent/buddy/companion.ts @@ -0,0 +1,105 @@ +/** + * Deterministic companion generation from user ID. + * Ported from cc-mini's buddy/companion.ts + * + * Same userId always produces the same CompanionBones. + */ + +import { + SPECIES, + EYES, + HATS, + RARITIES, + RARITY_WEIGHTS, + RARITY_FLOOR, + STAT_NAMES, + type CompanionBones, + type Rarity, + type StatName, +} from "./types"; + +const MASK = 0xFFFFFFFF; + +// Mulberry32 — seeded PRNG, same algorithm as cc-mini +function mulberry32(seed: number): () => number { + let a = seed & MASK; + return () => { + a = (a | 0) & MASK; + a = (a + 0x6D2B79F5) & MASK; + let t = ((a ^ (a >>> 15)) * (1 | a)) & MASK; + t = (t + (((t ^ (t >>> 7)) * (61 | t)) & MASK)) & MASK; + return ((t ^ (t >>> 14)) & MASK) / 4294967296; + }; +} + +// FNV-1a hash +function hashString(s: string): number { + let h = 2166136261; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619) & MASK; + } + return h & MASK; +} + +function pick<T>(rng: () => number, arr: readonly T[]): T { + return arr[Math.floor(rng() * arr.length)]; +} + +function rollRarity(rng: () => number): Rarity { + const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0); + let r = rng() * total; + for (const rarity of RARITIES) { + r -= RARITY_WEIGHTS[rarity]; + if (r < 0) return rarity; + } + return "common"; +} + +function rollStats(rng: () => number, rarity: Rarity): Record<StatName, number> { + const floor = RARITY_FLOOR[rarity]; + const peak = pick(rng, STAT_NAMES); + let dump = pick(rng, STAT_NAMES); + while (dump === peak) dump = pick(rng, STAT_NAMES); + + const stats = {} as Record<StatName, number>; + for (const name of STAT_NAMES) { + if (name === peak) { + stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30)); + } else if (name === dump) { + stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15)); + } else { + stats[name] = floor + Math.floor(rng() * 40); + } + } + return stats; +} + +const SALT = "friend-2026-401"; + +export interface Roll { + bones: CompanionBones; + inspirationSeed: number; +} + +function rollFrom(rng: () => number): Roll { + const rarity = rollRarity(rng); + const bones: CompanionBones = { + rarity, + species: pick(rng, SPECIES), + eye: pick(rng, EYES), + hat: rarity === "common" ? "none" : pick(rng, HATS), + shiny: rng() < 0.01, + stats: rollStats(rng, rarity), + }; + return { bones, inspirationSeed: Math.floor(rng() * 1e9) }; +} + +export function roll(userId: string): Roll { + const key = userId + SALT; + return rollFrom(mulberry32(hashString(key))); +} + +export function rollWithSeed(seed: string): Roll { + return rollFrom(mulberry32(hashString(seed))); +} diff --git a/src/lib/agent/buddy/storage.ts b/src/lib/agent/buddy/storage.ts new file mode 100644 index 00000000..d219eaae --- /dev/null +++ b/src/lib/agent/buddy/storage.ts @@ -0,0 +1,51 @@ +/** + * Client-side localStorage persistence for buddy companion data. + */ + +import type { StoredCompanion, Companion } from "./types"; +import { rollWithSeed } from "./companion"; + +const STORAGE_KEY = "buddy-companion"; + +export function loadStoredCompanion(): StoredCompanion | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as StoredCompanion; + } catch { + return null; + } +} + +export function saveStoredCompanion(stored: StoredCompanion): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); + } catch { /* ignore */ } +} + +export function deleteStoredCompanion(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { /* ignore */ } +} + +export function getCompanion(): Companion | null { + const stored = loadStoredCompanion(); + if (!stored) return null; + + const { bones } = rollWithSeed(stored.seed); + return { + ...bones, + name: stored.name, + personality: stored.personality, + hatchedAt: stored.hatchedAt, + muted: stored.muted ?? false, + }; +} + +export function setMuted(muted: boolean): void { + const stored = loadStoredCompanion(); + if (!stored) return; + stored.muted = muted; + saveStoredCompanion(stored); +} diff --git a/src/lib/agent/buddy/types.ts b/src/lib/agent/buddy/types.ts new file mode 100644 index 00000000..f2b511a3 --- /dev/null +++ b/src/lib/agent/buddy/types.ts @@ -0,0 +1,99 @@ +/** + * Buddy companion type definitions and constants. + * Ported from cc-mini's buddy/types.py + */ + +export const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"] as const; +export type Rarity = (typeof RARITIES)[number]; + +export const RARITY_WEIGHTS: Record<Rarity, number> = { + common: 60, + uncommon: 25, + rare: 10, + epic: 4, + legendary: 1, +}; + +export const RARITY_STARS: Record<Rarity, string> = { + common: "\u2605", + uncommon: "\u2605\u2605", + rare: "\u2605\u2605\u2605", + epic: "\u2605\u2605\u2605\u2605", + legendary: "\u2605\u2605\u2605\u2605\u2605", +}; + +export const RARITY_COLORS: Record<Rarity, string> = { + common: "#8b949e", + uncommon: "#3fb950", + rare: "#58a6ff", + epic: "#bc8cff", + legendary: "#d2a8ff", +}; + +export const RARITY_FLOOR: Record<Rarity, number> = { + common: 5, + uncommon: 15, + rare: 25, + epic: 35, + legendary: 50, +}; + +export const SPECIES = [ + "duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin", + "turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot", + "rabbit", "mushroom", "chonk", +] as const; +export type Species = (typeof SPECIES)[number]; + +export const SPECIES_EMOJI: Record<string, string> = { + duck: "\ud83e\udd86", + goose: "\ud83e\udeb7", + blob: "\ud83e\udea0", + cat: "\ud83d\udc31", + dragon: "\ud83d\udc09", + octopus: "\ud83d\udc19", + owl: "\ud83e\udd89", + penguin: "\ud83d\udc27", + turtle: "\ud83d\udc22", + snail: "\ud83d\udc0c", + ghost: "\ud83d\udc7b", + axolotl: "\ud83e\udd8e", + capybara: "\ud83e\uddab", + cactus: "\ud83c\udf35", + robot: "\ud83e\udd16", + rabbit: "\ud83d\udc30", + mushroom: "\ud83c\udf44", + chonk: "\ud83d\udc3b", +}; + +export const EYES = ["\u00b7", "\u2726", "\u00d7", "\u25c9", "@", "\u00b0"] as const; +export const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"] as const; +export const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"] as const; +export type StatName = (typeof STAT_NAMES)[number]; + +export interface CompanionBones { + rarity: Rarity; + species: string; + eye: string; + hat: string; + shiny: boolean; + stats: Record<StatName, number>; +} + +export interface CompanionSoul { + name: string; + personality: string; +} + +export interface Companion extends CompanionBones, CompanionSoul { + hatchedAt: number; + muted: boolean; +} + +export interface StoredCompanion { + name: string; + personality: string; + hatchedAt: number; + seed: string; + muted: boolean; +} diff --git a/src/lib/agent/conversation-compaction.ts b/src/lib/agent/conversation-compaction.ts new file mode 100644 index 00000000..b6281bfd --- /dev/null +++ b/src/lib/agent/conversation-compaction.ts @@ -0,0 +1,121 @@ +import type { UIMessage } from "ai"; +import { getMessageTextLength } from "@/lib/ai/models"; + +export type CompactionTrigger = "overflow" | "clear"; + +export interface OverflowCompactionPlan { + toSummarize: UIMessage[]; + toKeep: UIMessage[]; +} + +export function getRenderableMessages(messages: UIMessage[]): UIMessage[] { + return messages.filter((message) => getMessageTextLength(message) > 0); +} + +export function buildOverflowCompactionPlan(input: { + messages: UIMessage[]; + overflowThreshold: number; + failedAtCount: number; + minimumMessageCount?: number; + keepRatio?: number; + minimumKeepCount?: number; +}): OverflowCompactionPlan | null { + const minimumMessageCount = input.minimumMessageCount ?? 4; + const keepRatio = input.keepRatio ?? 0.2; + const minimumKeepCount = input.minimumKeepCount ?? 2; + const { messages, overflowThreshold, failedAtCount } = input; + + if (messages.length < minimumMessageCount) return null; + if (messages.length === failedAtCount) return null; + + const messageSizes = messages.map((message) => getMessageTextLength(message)); + const totalChars = messageSizes.reduce((sum, size) => sum + size, 0); + if (totalChars <= overflowThreshold) return null; + + let keepFromIndex = messages.length; + let accumulatedChars = 0; + const targetKeepChars = totalChars * keepRatio; + + for (let index = messages.length - 1; index >= 0; index--) { + accumulatedChars += messageSizes[index]; + if (accumulatedChars >= targetKeepChars) { + keepFromIndex = index; + break; + } + } + + keepFromIndex = Math.min(keepFromIndex, messages.length - minimumKeepCount); + if (keepFromIndex <= 0) return null; + + return { + toSummarize: messages.slice(0, keepFromIndex), + toKeep: messages.slice(keepFromIndex), + }; +} + +export function excludeKeptMessages( + messages: UIMessage[], + messagesToKeep: UIMessage[], +): UIMessage[] { + const keepIds = new Set(messagesToKeep.map((message) => message.id)); + return messages.filter((message) => !keepIds.has(message.id)); +} + +export async function requestConversationSummaryPreview(input: { + workspaceId: string; + messages: UIMessage[]; + trigger: CompactionTrigger; + locale?: string; + sessionName?: string; + compact?: boolean; +}): Promise<{ title: string; content: string }> { + const response = await fetch("/api/agent/summarize", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspaceId: input.workspaceId, + messages: input.messages, + trigger: input.trigger, + preview: true, + compact: input.compact, + locale: input.locale, + sessionName: input.sessionName, + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({ error: "Summarization failed" })); + throw new Error(data.error || "Summarization failed"); + } + + const data = await response.json(); + return { + title: typeof data.title === "string" ? data.title : "", + content: typeof data.content === "string" ? data.content : "", + }; +} + +export async function saveConversationMemoryNote(input: { + workspaceId: string; + title: string; + content: string; +}): Promise<void> { + const response = await fetch("/api/notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + workspaceId: input.workspaceId, + title: input.title, + content: input.content, + type: "memory", + }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => null); + const errorMessage = data && typeof data.error === "string" + ? data.error + : "Failed to save memory note"; + throw new Error(errorMessage); + } +} diff --git a/src/lib/agent/cost-tracker.ts b/src/lib/agent/cost-tracker.ts new file mode 100644 index 00000000..73fd2f0b --- /dev/null +++ b/src/lib/agent/cost-tracker.ts @@ -0,0 +1,220 @@ +/** + * Token usage and cost tracking for agent sessions. + * Ported from cc-mini's cost_tracker.py + */ + +// --------------------------------------------------------------------------- +// Pricing per million tokens ($/MTok) +// --------------------------------------------------------------------------- + +interface PricingTier { + input: number; + output: number; + cacheWrite: number; + cacheRead: number; +} + +const TIER_3_15: PricingTier = { input: 3.0, output: 15.0, cacheWrite: 3.75, cacheRead: 0.30 }; +const TIER_15_75: PricingTier = { input: 15.0, output: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }; +const TIER_5_25: PricingTier = { input: 5.0, output: 25.0, cacheWrite: 6.25, cacheRead: 0.50 }; +const TIER_HAIKU_35: PricingTier = { input: 0.80, output: 4.0, cacheWrite: 1.0, cacheRead: 0.08 }; +const TIER_HAIKU_45: PricingTier = { input: 1.0, output: 5.0, cacheWrite: 1.25, cacheRead: 0.10 }; + +// OpenAI pricing (approximate) +const TIER_GPT4O: PricingTier = { input: 2.50, output: 10.0, cacheWrite: 2.50, cacheRead: 1.25 }; +const TIER_GPT4O_MINI: PricingTier = { input: 0.15, output: 0.60, cacheWrite: 0.15, cacheRead: 0.075 }; +const TIER_GPT41: PricingTier = { input: 2.0, output: 8.0, cacheWrite: 2.0, cacheRead: 0.50 }; +const TIER_GPT41_MINI: PricingTier = { input: 0.40, output: 1.60, cacheWrite: 0.40, cacheRead: 0.10 }; +const TIER_GPT41_NANO: PricingTier = { input: 0.10, output: 0.40, cacheWrite: 0.10, cacheRead: 0.025 }; +const TIER_O3: PricingTier = { input: 2.0, output: 8.0, cacheWrite: 2.0, cacheRead: 0.50 }; +const TIER_O3_MINI: PricingTier = { input: 1.10, output: 4.40, cacheWrite: 1.10, cacheRead: 0.275 }; +const TIER_O4_MINI: PricingTier = { input: 1.10, output: 4.40, cacheWrite: 1.10, cacheRead: 0.275 }; + +// Gemini pricing +const TIER_GEMINI_FLASH: PricingTier = { input: 0.15, output: 0.60, cacheWrite: 0.15, cacheRead: 0.04 }; +const TIER_GEMINI_PRO: PricingTier = { input: 1.25, output: 10.0, cacheWrite: 1.25, cacheRead: 0.32 }; + +// Model prefix/substring -> tier. First match wins. +const MODEL_PRICING: [string, PricingTier][] = [ + // Anthropic + ["claude-3-5-haiku", TIER_HAIKU_35], + ["claude-haiku-4-5", TIER_HAIKU_45], + ["claude-opus-4-6", TIER_5_25], + ["claude-opus-4-5", TIER_5_25], + ["claude-opus-4-1", TIER_15_75], + ["claude-opus-4", TIER_15_75], + ["claude-sonnet", TIER_3_15], + ["claude-3-5-sonnet", TIER_3_15], + ["claude-3-7-sonnet", TIER_3_15], + // OpenAI + ["gpt-5", TIER_GPT4O], + ["gpt-4o-mini", TIER_GPT4O_MINI], + ["gpt-4o", TIER_GPT4O], + ["gpt-4.1-nano", TIER_GPT41_NANO], + ["gpt-4.1-mini", TIER_GPT41_MINI], + ["gpt-4.1", TIER_GPT41], + ["o4-mini", TIER_O4_MINI], + ["o3-mini", TIER_O3_MINI], + ["o3", TIER_O3], + // Gemini + ["gemini-3", TIER_GEMINI_FLASH], + ["gemini-2.5-flash", TIER_GEMINI_FLASH], + ["gemini-2.5-pro", TIER_GEMINI_PRO], +]; + +const DEFAULT_TIER = TIER_3_15; + +function tierForModel(model: string): PricingTier | null { + const modelLower = model.toLowerCase(); + for (const [prefix, tier] of MODEL_PRICING) { + if (modelLower.includes(prefix)) { + return tier; + } + } + // Unknown non-Claude model + if (modelLower.startsWith("gpt-") || modelLower.startsWith("o1") || modelLower.startsWith("o3") || modelLower.startsWith("o4")) { + return null; + } + return DEFAULT_TIER; +} + +// --------------------------------------------------------------------------- +// Usage data +// --------------------------------------------------------------------------- + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + costUsd: number; + pricingKnown: boolean; +} + +export interface UsageData { + inputTokens?: number; + outputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + // AI SDK uses these names + promptTokens?: number; + completionTokens?: number; +} + +export interface CostSnapshot { + totalCostUsd: number; + totalInputTokens: number; + totalOutputTokens: number; + modelUsage: Record<string, ModelUsage>; + startTime: number; +} + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +export function formatTokens(n: number): string { + if (n >= 1_000_000) { + const v = n / 1_000_000; + return v === Math.floor(v) ? `${v}m` : `${v.toFixed(1)}m`; + } + if (n >= 1_000) { + const v = n / 1_000; + return v === Math.floor(v) ? `${v}k` : `${v.toFixed(1)}k`; + } + return String(n); +} + +// --------------------------------------------------------------------------- +// CostTracker +// --------------------------------------------------------------------------- + +export class CostTracker { + private _totalCostUsd = 0; + private _modelUsage: Record<string, ModelUsage> = {}; + private _startTime = Date.now(); + + get totalCostUsd(): number { + return this._totalCostUsd; + } + + get totalInputTokens(): number { + return Object.values(this._modelUsage).reduce((sum, mu) => sum + mu.inputTokens, 0); + } + + get totalOutputTokens(): number { + return Object.values(this._modelUsage).reduce((sum, mu) => sum + mu.outputTokens, 0); + } + + static calculateCost(model: string, usage: UsageData): number { + const tier = tierForModel(model); + if (!tier) return 0; + const inp = usage.inputTokens ?? usage.promptTokens ?? 0; + const out = usage.outputTokens ?? usage.completionTokens ?? 0; + const cacheRead = usage.cacheReadInputTokens ?? 0; + const cacheWrite = usage.cacheCreationInputTokens ?? 0; + const regularInput = Math.max(inp - cacheRead - cacheWrite, 0); + return ( + regularInput * tier.input + + out * tier.output + + cacheRead * tier.cacheRead + + cacheWrite * tier.cacheWrite + ) / 1_000_000; + } + + addUsage(model: string, usage: UsageData): number { + const cost = CostTracker.calculateCost(model, usage); + this._totalCostUsd += cost; + + const inp = usage.inputTokens ?? usage.promptTokens ?? 0; + const out = usage.outputTokens ?? usage.completionTokens ?? 0; + const cacheRead = usage.cacheReadInputTokens ?? 0; + const cacheWrite = usage.cacheCreationInputTokens ?? 0; + + if (!this._modelUsage[model]) { + this._modelUsage[model] = { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + costUsd: 0, + pricingKnown: tierForModel(model) !== null, + }; + } + const mu = this._modelUsage[model]; + mu.inputTokens += inp; + mu.outputTokens += out; + mu.cacheReadInputTokens += cacheRead; + mu.cacheCreationInputTokens += cacheWrite; + mu.costUsd += cost; + return cost; + } + + getSnapshot(): CostSnapshot { + return { + totalCostUsd: this._totalCostUsd, + totalInputTokens: this.totalInputTokens, + totalOutputTokens: this.totalOutputTokens, + modelUsage: { ...this._modelUsage }, + startTime: this._startTime, + }; + } + + toJSON(): CostSnapshot { + return this.getSnapshot(); + } + + static fromJSON(data: CostSnapshot): CostTracker { + const tracker = new CostTracker(); + tracker._totalCostUsd = data.totalCostUsd ?? 0; + tracker._modelUsage = data.modelUsage ?? {}; + tracker._startTime = data.startTime ?? Date.now(); + return tracker; + } + + reset(): void { + this._totalCostUsd = 0; + this._modelUsage = {}; + this._startTime = Date.now(); + } +} diff --git a/src/lib/agent/kairos-memory.ts b/src/lib/agent/kairos-memory.ts new file mode 100644 index 00000000..faf1198e --- /dev/null +++ b/src/lib/agent/kairos-memory.ts @@ -0,0 +1,114 @@ +/** + * KAIROS cross-session memory system. + * Ported from cc-mini's memory.py — daily logs, memory tag extraction, + * dream consolidation prompts, and system prompt injection. + */ + +/** + * Extract all <memory>...</memory> tag contents from text. + */ +export function extractMemoryTags(text: string): string[] { + const matches = text.match(/<memory>([\s\S]*?)<\/memory>/g); + if (!matches) return []; + return matches.map((m) => m.replace(/<\/?memory>/g, "").trim()).filter(Boolean); +} + +/** + * Build the KAIROS memory instructions for injection into the system prompt. + * Includes 4-type taxonomy, what to save/not save, and current memory index. + */ +export function buildMemorySystemSection(memoryIndex: string): string { + let section = ` +# Auto Memory + +You have a persistent memory system that spans conversations. +Use <memory>...</memory> tags in your responses to save important information. +These tags are automatically extracted and appended to the workspace memory log. + +## Types of memory + +### user +Information about the user's role, goals, preferences, and knowledge. +**When to save:** When you learn details about the user that shape how you should help. + +### feedback +Guidance or correction the user has given you about how to work. +**When to save:** Any time the user corrects your approach or confirms a non-obvious choice. + +### project +Information about ongoing work, goals, or decisions not derivable from code. +**When to save:** When you learn who is doing what, why, or by when. + +### reference +Pointers to external resources and their purpose. +**When to save:** When you learn about resources in external systems. + +## What NOT to save +- Code patterns, architecture, file paths — derivable from reading the project +- Git history or recent changes — use git log / git blame +- Debugging solutions — the fix is in the code +- Ephemeral task details or current conversation context + +## Slash commands +- \`/memory\` — show current memory index +- \`/remember <text>\` — manually append a note to the daily log +- \`/dream\` — consolidate daily logs into topic files +`; + + if (memoryIndex) { + section += `\n## Current Memory Index\n${memoryIndex}\n`; + } else { + section += "\nNo memories consolidated yet.\n"; + } + + return section; +} + +/** + * Build the dream consolidation prompt for the dream agent. + */ +export function buildDreamPrompt(workspaceId: string, dailyLogs: string[], existingMemories: string[]): string { + const logsSection = dailyLogs.length > 0 + ? dailyLogs.join("\n\n---\n\n") + : "No daily logs found."; + + const memoriesSection = existingMemories.length > 0 + ? existingMemories.join("\n\n---\n\n") + : "No existing memories."; + + return `You are running a KAIROS dream consolidation for workspace ${workspaceId}. +Your job is to read daily logs and existing memories, then produce a consolidated memory index. + +## Daily Logs +${logsSection} + +## Existing Memories +${memoriesSection} + +## Instructions +1. Group related entries by topic +2. Identify the memory type for each group (user, feedback, project, reference) +3. Merge similar entries, removing duplicates +4. Convert relative dates to absolute dates where possible +5. Produce a consolidated summary as a markdown document with sections for each topic + +Output the consolidated memory as a single markdown document. +Each section should have a clear heading and brief, actionable content. +Keep it concise — under 200 lines total.`; +} + +/** + * Format a daily log entry with timestamp. + */ +export function formatDailyLogEntry(entry: string): string { + const now = new Date(); + const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`; + return `- [${time}] ${entry}`; +} + +/** + * Get today's date key for daily log grouping. + */ +export function todayKey(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/src/lib/ai/agent-prompts.ts b/src/lib/ai/agent-prompts.ts index 3fe50dd2..e31f7335 100644 --- a/src/lib/ai/agent-prompts.ts +++ b/src/lib/ai/agent-prompts.ts @@ -157,15 +157,18 @@ Respond in the same language as the user's message.`; } /** - * Build a system prompt for Plan mode — read-only exploration and planning. + * Build a system prompt for read-only modes (Plan or Ask). */ -export function buildPlanSystemPrompt(cwd: string): string { - return `You are an expert software architect working in a web-based terminal. You have read-only access to the user's workspace at: ${cwd} - -## Available Tools +function buildReadOnlySystemPrompt(cwd: string, mode: "plan" | "ask"): string { + const toolsSection = `## Available Tools - **readFile**: Read file contents - **listDirectory**: List directory contents -- **grep**: Search for regex patterns in files +- **grep**: Search for regex patterns in files`; + + if (mode === "plan") { + return `You are an expert software architect working in a web-based terminal. You have read-only access to the user's workspace at: ${cwd} + +${toolsSection} ## Your Role You are in **Plan Mode**. Your job is to: @@ -190,18 +193,11 @@ ${CONTEXT_COMPACTION_SECTION} - You can only read files within the workspace directory. Respond in the same language as the user's message.`; -} + } -/** - * Build a system prompt for Ask mode — answer questions about code, read-only. - */ -export function buildAskSystemPrompt(cwd: string): string { return `You are an expert software engineer answering questions about a codebase. You have read-only access to the user's workspace at: ${cwd} -## Available Tools -- **readFile**: Read file contents -- **listDirectory**: List directory contents -- **grep**: Search for regex patterns in files +${toolsSection} ## Your Role You are in **Ask Mode** — a read-only mode. Your job is to: @@ -228,6 +224,20 @@ ${CONTEXT_COMPACTION_SECTION} Respond in the same language as the user's message.`; } +/** + * Build a system prompt for Plan mode — read-only exploration and planning. + */ +export function buildPlanSystemPrompt(cwd: string): string { + return buildReadOnlySystemPrompt(cwd, "plan"); +} + +/** + * Build a system prompt for Ask mode — answer questions about code, read-only. + */ +export function buildAskSystemPrompt(cwd: string): string { + return buildReadOnlySystemPrompt(cwd, "ask"); +} + /** * Build a system prompt for summarizing agent conversation into a memory note. */ diff --git a/src/lib/ai/agent-tools.ts b/src/lib/ai/agent-tools.ts deleted file mode 100644 index f85dc6e7..00000000 --- a/src/lib/ai/agent-tools.ts +++ /dev/null @@ -1 +0,0 @@ -export { createAgentTools } from "./tools"; diff --git a/src/lib/ai/models.ts b/src/lib/ai/models.ts index f499cd09..ca89fe1c 100644 --- a/src/lib/ai/models.ts +++ b/src/lib/ai/models.ts @@ -109,6 +109,7 @@ export const PROVIDERS = { name: "Qwen", models: [ { id: "Qwen3-235B", name: "Qwen3 235B", contextWindow: 200000, supportsVision: false }, + { id: "Qwen3.5-122B", name: "Qwen 3.5 122B", contextWindow: 40000, supportsVision: false }, { id: "qwen3.5-397b", name: "Qwen 3.5 397B", contextWindow: 200000, supportsVision: true }, ] satisfies ModelDefinition[], envKey: "QWEN_API_KEY", diff --git a/src/lib/ai/tools/search-tools-runtime.test.ts b/src/lib/ai/tools/search-tools-runtime.test.ts new file mode 100644 index 00000000..dfbca859 --- /dev/null +++ b/src/lib/ai/tools/search-tools-runtime.test.ts @@ -0,0 +1,18 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("search-tools startup", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("does not load paper-content during module import", async () => { + vi.doMock("@/lib/article-search/paper-content", () => { + throw new Error("paper-content should not load during module import"); + }); + + const imported = await import("./search-tools"); + + expect(imported.createSearchTools).toEqual(expect.any(Function)); + }); +}); diff --git a/src/lib/ai/tools/search-tools.ts b/src/lib/ai/tools/search-tools.ts index 28e34136..f3a46e17 100644 --- a/src/lib/ai/tools/search-tools.ts +++ b/src/lib/ai/tools/search-tools.ts @@ -7,7 +7,6 @@ import { findRelatedArticles, } from "@/lib/article-search"; import type { Article } from "@/lib/article-search"; -import { readPaperText, resolvePaperPdfUrl } from "@/lib/article-search/paper-content"; import { PAPER } from "@/lib/constants"; /** Format an Article for LLM-friendly output. */ @@ -157,6 +156,9 @@ export function createSearchTools() { .describe("Maximum number of characters to extract from the paper text"), }), execute: async ({ title, url, pdfUrl, source, maxChars }) => { + const { readPaperText, resolvePaperPdfUrl } = await import( + "@/lib/article-search/paper-content" + ); const result = await readPaperText( { title, diff --git a/src/lib/api-errors.test.ts b/src/lib/api-errors.test.ts new file mode 100644 index 00000000..cb4bcac4 --- /dev/null +++ b/src/lib/api-errors.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import type { NextRequest } from "next/server"; +import { + jsonException, + requiredSearchParam, + requiredStringFields, +} from "./api-errors"; + +function requestWithUrl(url: string): NextRequest { + return { nextUrl: new URL(url) } as NextRequest; +} + +describe("api error helpers", () => { + it("maps access-denied exceptions to 403 JSON errors", async () => { + const response = jsonException(new Error("Access denied"), "fallback"); + + expect(response.status).toBe(403); + await expect(response.json()).resolves.toEqual({ error: "Access denied" }); + }); + + it("uses the fallback message for unknown exceptions", async () => { + const response = jsonException("boom", "Failed to handle request"); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toEqual({ + error: "Failed to handle request", + }); + }); + + it("returns required search params or a 400 response", async () => { + expect( + requiredSearchParam( + requestWithUrl("http://localhost/api/test?workspaceId=abc"), + "workspaceId", + ), + ).toBe("abc"); + + const missing = requiredSearchParam( + requestWithUrl("http://localhost/api/test"), + "workspaceId", + ); + + expect(typeof missing).not.toBe("string"); + if (typeof missing !== "string") { + expect(missing.status).toBe(400); + await expect(missing.json()).resolves.toEqual({ + error: "Missing workspaceId", + }); + } + }); + + it("validates required string fields without changing message text", async () => { + expect( + requiredStringFields( + { name: "Report", schedule: "0 9 * * *" }, + ["name", "schedule"], + "Missing required fields", + ), + ).toBeNull(); + + const response = requiredStringFields( + { name: "Report", schedule: "" }, + ["name", "schedule"], + "Missing required fields", + ); + + expect(response?.status).toBe(400); + await expect(response?.json()).resolves.toEqual({ + error: "Missing required fields", + }); + }); +}); diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index f0f790c7..b4f069a7 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -1,4 +1,4 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; /** Standard JSON error response for non-streaming API routes. */ export function jsonError(message: string, status: number) { @@ -9,3 +9,44 @@ export function jsonError(message: string, status: number) { export function textError(message: string, status: number) { return new Response(message, { status }); } + +export function errorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} + +export function jsonException( + error: unknown, + fallback: string, + options?: { accessDeniedStatus?: number; status?: number }, +) { + const message = errorMessage(error, fallback); + const status = + options?.status ?? + (message.includes("Access denied") ? options?.accessDeniedStatus ?? 403 : 500); + return jsonError(message, status); +} + +export function requiredSearchParam( + request: NextRequest, + name: string, + message = `Missing ${name}`, +): string | NextResponse { + const value = request.nextUrl.searchParams.get(name); + if (!value) { + return jsonError(message, 400); + } + return value; +} + +export function requiredStringFields( + body: Record<string, unknown>, + fields: readonly string[], + message: string, +): NextResponse | null { + const missing = fields.some((field) => { + const value = body[field]; + return typeof value !== "string" || !value.trim(); + }); + + return missing ? jsonError(message, 400) : null; +} diff --git a/src/lib/auth/constants.ts b/src/lib/auth/constants.ts new file mode 100644 index 00000000..2d362690 --- /dev/null +++ b/src/lib/auth/constants.ts @@ -0,0 +1,17 @@ +export const AUTH_SESSION_COOKIE = "innoclaw_session"; +export const AUTH_SESSION_EXPIRES_COOKIE = "innoclaw_session_expires"; +export const AUTH_SESSION_SIGNATURE_COOKIE = "innoclaw_session_sig"; + +export const AUTH_SESSION_DAYS = 30; +export const AUTH_SESSION_REFRESH_DAYS = 7; + +export const AUTH_PUBLIC_PATHS = new Set([ + "/login", + "/register", +]); + +export const AUTH_PUBLIC_API_PREFIXES = [ + "/api/auth", + "/api/bot/feishu", + "/api/bot/wechat", +]; diff --git a/src/lib/auth/ownership.ts b/src/lib/auth/ownership.ts new file mode 100644 index 00000000..a591cf47 --- /dev/null +++ b/src/lib/auth/ownership.ts @@ -0,0 +1,198 @@ +import path from "path"; +import { and, eq, isNull, or } from "drizzle-orm"; +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { deepResearchSessions, hfDatasets, notes, scheduledTasks, skills, workspaces } from "@/lib/db/schema"; +import { isWithinWorkspace } from "@/lib/files/filesystem"; +import { forbiddenResponse, requireAuth, type AuthContext } from "./server"; + +export function ownedWorkspaceFilter(auth: AuthContext) { + if (auth.user.role === "admin") { + return or(eq(workspaces.ownerUserId, auth.user.id), isNull(workspaces.ownerUserId)); + } + + return eq(workspaces.ownerUserId, auth.user.id); +} + +export function ownedDatasetFilter(auth: AuthContext) { + return auth.user.role === "admin" + ? or(eq(hfDatasets.ownerUserId, auth.user.id), isNull(hfDatasets.ownerUserId)) + : eq(hfDatasets.ownerUserId, auth.user.id); +} + +export function ownedScheduledTaskFilter(auth: AuthContext) { + return auth.user.role === "admin" + ? or(eq(scheduledTasks.ownerUserId, auth.user.id), isNull(scheduledTasks.ownerUserId)) + : eq(scheduledTasks.ownerUserId, auth.user.id); +} + +export function ownedSkillFilter(auth: AuthContext) { + return or(eq(skills.ownerUserId, auth.user.id), isNull(skills.ownerUserId)); +} + +export async function requireWorkspaceAccess( + request: NextRequest, + workspaceId: string, +): Promise<{ auth: AuthContext; workspace: typeof workspaces.$inferSelect } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [workspace] = await db + .select() + .from(workspaces) + .where(and(eq(workspaces.id, workspaceId), ownedWorkspaceFilter(auth))) + .limit(1); + + if (!workspace) { + return forbiddenResponse("Workspace access denied"); + } + + return { auth, workspace }; +} + +export async function requireDatasetAccess( + request: NextRequest, + datasetId: string, +): Promise<{ auth: AuthContext; dataset: typeof hfDatasets.$inferSelect } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [dataset] = await db + .select() + .from(hfDatasets) + .where(and(eq(hfDatasets.id, datasetId), ownedDatasetFilter(auth))) + .limit(1); + + if (!dataset) { + return forbiddenResponse("Dataset access denied"); + } + + return { auth, dataset }; +} + +export async function requireNoteAccess( + request: NextRequest, + noteId: string, +): Promise<{ auth: AuthContext; note: typeof notes.$inferSelect } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [row] = await db + .select({ note: notes }) + .from(notes) + .innerJoin(workspaces, eq(notes.workspaceId, workspaces.id)) + .where(and(eq(notes.id, noteId), ownedWorkspaceFilter(auth))) + .limit(1); + + if (!row) { + return forbiddenResponse("Note access denied"); + } + + return { auth, note: row.note }; +} + +export async function requireSkillAccess( + request: NextRequest, + skillId: string, +): Promise<{ auth: AuthContext; skill: typeof skills.$inferSelect } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [skill] = await db + .select() + .from(skills) + .where(and(eq(skills.id, skillId), ownedSkillFilter(auth))) + .limit(1); + + if (!skill) { + return forbiddenResponse("Skill access denied"); + } + + return { auth, skill }; +} + +export async function requireDeepResearchSessionAccess( + request: NextRequest, + sessionId: string, +): Promise<{ auth: AuthContext; session: typeof deepResearchSessions.$inferSelect } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [session] = await db + .select({ session: deepResearchSessions }) + .from(deepResearchSessions) + .innerJoin(workspaces, eq(deepResearchSessions.workspaceId, workspaces.id)) + .where(and(eq(deepResearchSessions.id, sessionId), ownedWorkspaceFilter(auth))) + .limit(1); + + if (!session) { + return forbiddenResponse("Session access denied"); + } + + return { auth, session: session.session }; +} + +export async function requirePathAccess( + request: NextRequest, + targetPath: string, +): Promise<{ auth: AuthContext } | NextResponse> { + return requireWorkspacePathsAccess(request, [targetPath]); +} + +export async function requireScheduledTaskAccess( + request: NextRequest, + taskId: string, +): Promise<{ auth: AuthContext; task: typeof scheduledTasks.$inferSelect } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const [task] = await db + .select() + .from(scheduledTasks) + .where(and(eq(scheduledTasks.id, taskId), ownedScheduledTaskFilter(auth))) + .limit(1); + + if (!task) { + return NextResponse.json({ error: "Task not found" }, { status: 404 }); + } + + return { auth, task }; +} + +export async function requireWorkspacePathsAccess( + request: NextRequest, + targetPaths: string[], +): Promise<{ auth: AuthContext } | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const rows = await db + .select({ folderPath: workspaces.folderPath }) + .from(workspaces) + .where(ownedWorkspaceFilter(auth)); + + const allowed = targetPaths.every((targetPath) => { + const resolved = path.resolve(targetPath); + return rows.some((row) => isWithinWorkspace(resolved, row.folderPath)); + }); + + if (!allowed) { + return forbiddenResponse("Path access denied"); + } + + return { auth }; +} diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts new file mode 100644 index 00000000..8f4266aa --- /dev/null +++ b/src/lib/auth/password.ts @@ -0,0 +1,24 @@ +import crypto from "crypto"; + +const SCRYPT_KEY_LENGTH = 64; + +export function hashPassword(password: string): string { + const salt = crypto.randomBytes(16).toString("base64url"); + const hash = crypto + .scryptSync(password, salt, SCRYPT_KEY_LENGTH) + .toString("base64url"); + + return `scrypt:${salt}:${hash}`; +} + +export function verifyPassword(password: string, storedHash: string): boolean { + const [algorithm, salt, hash] = storedHash.split(":"); + if (algorithm !== "scrypt" || !salt || !hash) { + return false; + } + + const expected = Buffer.from(hash, "base64url"); + const actual = crypto.scryptSync(password, salt, expected.length); + + return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); +} diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts new file mode 100644 index 00000000..536fdbe8 --- /dev/null +++ b/src/lib/auth/server.ts @@ -0,0 +1,335 @@ +import crypto from "crypto"; +import { cookies } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; +import { and, count, eq, gt, isNull } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import { db } from "@/lib/db"; +import { + hfDatasets, + scheduledTasks, + skills, + userSessions, + users, + workspaces, +} from "@/lib/db/schema"; +import { + AUTH_SESSION_COOKIE, + AUTH_SESSION_DAYS, + AUTH_SESSION_EXPIRES_COOKIE, + AUTH_SESSION_REFRESH_DAYS, + AUTH_SESSION_SIGNATURE_COOKIE, +} from "./constants"; +import type { PublicUser } from "@/types/auth"; + +export type AuthRole = "admin" | "user"; + +export interface AuthContext { + user: PublicUser; + session: { + id: string; + expiresAt: string; + }; + token: string; +} + +function sessionDurationMs(): number { + return AUTH_SESSION_DAYS * 24 * 60 * 60 * 1000; +} + +function sessionRefreshMs(): number { + return AUTH_SESSION_REFRESH_DAYS * 24 * 60 * 60 * 1000; +} + +function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +function toPublicUser(user: typeof users.$inferSelect): PublicUser { + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + isActive: user.isActive, + lastLoginAt: user.lastLoginAt, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; +} + +export function normalizeUserEmail(email: string): string { + return normalizeEmail(email); +} + +export function hashSessionToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("base64url"); +} + +function getSessionSigningSecret(): string { + return ( + process.env.AUTH_SECRET || + process.env.NEXTAUTH_SECRET || + "innoclaw-development-secret" + ); +} + +export function signSessionToken(token: string): string { + return crypto + .createHmac("sha256", getSessionSigningSecret()) + .update(token) + .digest("base64url"); +} + +export function verifySessionSignature(token: string, signature: string): boolean { + return signSessionToken(token) === signature; +} + +export function createRawSessionToken(): string { + return crypto.randomBytes(32).toString("base64url"); +} + +export function getSessionExpiresAt(): string { + return new Date(Date.now() + sessionDurationMs()).toISOString(); +} + +function setCookiePair(response: NextResponse, token: string, expiresAt: string): void { + const expires = new Date(expiresAt); + const common = { + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + path: "/", + expires, + }; + + response.cookies.set(AUTH_SESSION_COOKIE, token, common); + response.cookies.set(AUTH_SESSION_EXPIRES_COOKIE, expiresAt, common); + response.cookies.set(AUTH_SESSION_SIGNATURE_COOKIE, signSessionToken(token), { + ...common, + httpOnly: true, + }); +} + +export function clearAuthCookies(response: NextResponse): void { + response.cookies.set(AUTH_SESSION_COOKIE, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + expires: new Date(0), + }); + response.cookies.set(AUTH_SESSION_EXPIRES_COOKIE, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + expires: new Date(0), + }); + response.cookies.set(AUTH_SESSION_SIGNATURE_COOKIE, "", { + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + path: "/", + expires: new Date(0), + }); +} + +export async function getUserCount(): Promise<number> { + const [row] = await db.select({ count: count() }).from(users); + return row.count; +} + +export async function findUserByEmail(email: string) { + const [user] = await db + .select() + .from(users) + .where(eq(users.email, normalizeEmail(email))) + .limit(1); + + return user ?? null; +} + +export async function claimExistingDataForFirstUser(userId: string): Promise<void> { + await db.update(workspaces).set({ ownerUserId: userId }).where(isNull(workspaces.ownerUserId)); + await db.update(hfDatasets).set({ ownerUserId: userId }).where(isNull(hfDatasets.ownerUserId)); + await db.update(scheduledTasks).set({ ownerUserId: userId }).where(isNull(scheduledTasks.ownerUserId)); + await db + .update(skills) + .set({ ownerUserId: userId }) + .where(and(isNull(skills.ownerUserId), isNull(skills.workspaceId))); +} + +export async function createUser(input: { + email: string; + name?: string; + passwordHash: string; + role?: AuthRole; + isActive?: boolean; +}): Promise<PublicUser> { + const now = new Date().toISOString(); + const email = normalizeEmail(input.email); + const fallbackName = email.split("@")[0] || "User"; + const [created] = await db + .insert(users) + .values({ + id: nanoid(), + email, + name: input.name?.trim() || fallbackName, + passwordHash: input.passwordHash, + role: input.role ?? "user", + isActive: input.isActive ?? true, + createdAt: now, + updatedAt: now, + }) + .returning(); + + return toPublicUser(created); +} + +export async function createAuthSession(userId: string): Promise<{ + token: string; + expiresAt: string; +}> { + const token = createRawSessionToken(); + const expiresAt = getSessionExpiresAt(); + const now = new Date().toISOString(); + + await db.insert(userSessions).values({ + id: nanoid(), + userId, + tokenHash: hashSessionToken(token), + expiresAt, + lastSeenAt: now, + createdAt: now, + }); + + return { token, expiresAt }; +} + +export function attachAuthCookies( + response: NextResponse, + session: { token: string; expiresAt: string }, +): NextResponse { + setCookiePair(response, session.token, session.expiresAt); + return response; +} + +async function getTokenFromCookies(): Promise<string | null> { + const cookieStore = await cookies(); + return cookieStore.get(AUTH_SESSION_COOKIE)?.value ?? null; +} + +function getTokenFromRequest(request: NextRequest): string | null { + return request.cookies.get(AUTH_SESSION_COOKIE)?.value ?? null; +} + +export async function getAuthContext(request?: NextRequest): Promise<AuthContext | null> { + const token = request ? getTokenFromRequest(request) : await getTokenFromCookies(); + if (!token) { + return null; + } + + const now = new Date().toISOString(); + const [row] = await db + .select({ + session: userSessions, + user: users, + }) + .from(userSessions) + .innerJoin(users, eq(userSessions.userId, users.id)) + .where( + and( + eq(userSessions.tokenHash, hashSessionToken(token)), + isNull(userSessions.revokedAt), + gt(userSessions.expiresAt, now), + eq(users.isActive, true), + ), + ) + .limit(1); + + if (!row) { + return null; + } + + const lastSeenAt = row.session.lastSeenAt; + const shouldTouch = + !lastSeenAt || + Date.now() - new Date(lastSeenAt).getTime() > 10 * 60 * 1000; + + if (shouldTouch) { + await db + .update(userSessions) + .set({ lastSeenAt: now }) + .where(eq(userSessions.id, row.session.id)); + } + + return { + user: toPublicUser(row.user), + session: { + id: row.session.id, + expiresAt: row.session.expiresAt, + }, + token, + }; +} + +export async function refreshAuthSessionIfNeeded( + response: NextResponse, + auth: AuthContext, +): Promise<NextResponse> { + const expiresAtMs = new Date(auth.session.expiresAt).getTime(); + if (expiresAtMs - Date.now() > sessionRefreshMs()) { + return response; + } + + const nextExpiresAt = getSessionExpiresAt(); + await db + .update(userSessions) + .set({ + expiresAt: nextExpiresAt, + lastSeenAt: new Date().toISOString(), + }) + .where(eq(userSessions.id, auth.session.id)); + + setCookiePair(response, auth.token, nextExpiresAt); + return response; +} + +export async function revokeCurrentSession(request: NextRequest): Promise<void> { + const token = getTokenFromRequest(request); + if (!token) { + return; + } + + await db + .update(userSessions) + .set({ revokedAt: new Date().toISOString() }) + .where(eq(userSessions.tokenHash, hashSessionToken(token))); +} + +export function unauthorizedResponse(message = "Unauthorized"): NextResponse { + return NextResponse.json({ error: message }, { status: 401 }); +} + +export function forbiddenResponse(message = "Forbidden"): NextResponse { + return NextResponse.json({ error: message }, { status: 403 }); +} + +export async function requireAuth(request: NextRequest): Promise<AuthContext | NextResponse> { + const auth = await getAuthContext(request); + if (!auth) { + return unauthorizedResponse(); + } + return auth; +} + +export async function requireAdmin(request: NextRequest): Promise<AuthContext | NextResponse> { + const auth = await requireAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + if (auth.user.role !== "admin") { + return forbiddenResponse("Admin access required"); + } + return auth; +} diff --git a/src/lib/bot/feishu/agent-processor-runtime.test.ts b/src/lib/bot/feishu/agent-processor-runtime.test.ts new file mode 100644 index 00000000..77ef76e3 --- /dev/null +++ b/src/lib/bot/feishu/agent-processor-runtime.test.ts @@ -0,0 +1,18 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("agent-processor startup", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("does not load the tool registry during module import", async () => { + vi.doMock("@/lib/ai/tools", () => { + throw new Error("tool registry should not load during module import"); + }); + + const imported = await import("./agent-processor"); + + expect(imported.processAgentMessage).toEqual(expect.any(Function)); + }); +}); diff --git a/src/lib/bot/feishu/agent-processor.ts b/src/lib/bot/feishu/agent-processor.ts index edd8424c..48c64b86 100644 --- a/src/lib/bot/feishu/agent-processor.ts +++ b/src/lib/bot/feishu/agent-processor.ts @@ -13,7 +13,6 @@ import { type UIMessage, } from "ai"; import { getConfiguredModel, isAIAvailable } from "@/lib/ai/provider"; -import { createAgentTools } from "@/lib/ai/agent-tools"; import { buildAgentSystemPrompt, buildPlanSystemPrompt, @@ -73,6 +72,7 @@ function getSystemPrompt(mode: AgentMode, cwd: string): string { } async function getTools(mode: AgentMode, cwd: string) { + const { createAgentTools } = await import("@/lib/ai/tools"); if (mode === "plan" || mode === "ask") { return createAgentTools(cwd, ["readFile", "listDirectory", "grep"]); } diff --git a/src/lib/bot/feishu/ws-client-runtime.test.ts b/src/lib/bot/feishu/ws-client-runtime.test.ts new file mode 100644 index 00000000..ae1fba42 --- /dev/null +++ b/src/lib/bot/feishu/ws-client-runtime.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("startFeishuWSClient", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + delete (globalThis as { __feishuWsStarted?: boolean }).__feishuWsStarted; + delete (globalThis as { __feishuWsClient?: unknown }).__feishuWsClient; + delete (globalThis as { __feishuWsUnsupported?: boolean }).__feishuWsUnsupported; + }); + + it("falls back to webhook mode when the installed SDK has no WSClient export", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + vi.doMock("@larksuiteoapi/node-sdk", () => ({ + Domain: { Feishu: "feishu" }, + LoggerLevel: { + error: 1, + warn: 2, + info: 3, + debug: 4, + trace: 5, + }, + WSClient: undefined, + EventDispatcher: class { + register() { + return this; + } + }, + })); + vi.doMock("../types", () => ({ + getFeishuConfig: () => ({ + enabled: true, + appId: "app-id", + appSecret: "app-secret", + verificationToken: "token", + }), + })); + vi.doMock("./client", () => ({ + createFeishuAdapter: () => ({ + parseMessages: () => [], + }), + })); + vi.doMock("./message-handler", () => ({ + handleFeishuMessage: vi.fn(), + })); + + const { startFeishuWSClient } = await import("./ws-client"); + startFeishuWSClient(); + + expect(warnSpy).toHaveBeenCalledWith( + "[feishu-ws] Installed @larksuiteoapi/node-sdk does not export WSClient. " + + "Skipping Feishu WebSocket startup and keeping HTTP webhook mode enabled at /api/bot/feishu." + ); + expect((globalThis as { __feishuWsUnsupported?: boolean }).__feishuWsUnsupported).toBe(true); + expect((globalThis as { __feishuWsStarted?: boolean }).__feishuWsStarted).toBeUndefined(); + }); + + it("starts the SDK WSClient when the export is available", async () => { + const startMock = vi.fn().mockResolvedValue(undefined); + const ctorSpy = vi.fn(); + class WsClientMock { + close = vi.fn(); + + constructor(options: unknown) { + ctorSpy(options); + } + + start = startMock; + } + + vi.spyOn(console, "log").mockImplementation(() => {}); + + vi.doMock("@larksuiteoapi/node-sdk", () => ({ + Domain: { Feishu: "feishu" }, + LoggerLevel: { + error: 1, + warn: 2, + info: 3, + debug: 4, + trace: 5, + }, + WSClient: WsClientMock, + EventDispatcher: class { + register() { + return this; + } + }, + })); + vi.doMock("../types", () => ({ + getFeishuConfig: () => ({ + enabled: true, + appId: "app-id", + appSecret: "app-secret", + verificationToken: "token", + }), + })); + vi.doMock("./client", () => ({ + createFeishuAdapter: () => ({ + parseMessages: () => [], + }), + })); + vi.doMock("./message-handler", () => ({ + handleFeishuMessage: vi.fn(), + })); + + const { startFeishuWSClient } = await import("./ws-client"); + startFeishuWSClient(); + await Promise.resolve(); + + expect(ctorSpy).toHaveBeenCalledTimes(1); + expect(ctorSpy).toHaveBeenCalledWith(expect.objectContaining({ + appId: "app-id", + appSecret: "app-secret", + domain: "feishu", + })); + expect(startMock).toHaveBeenCalledTimes(1); + expect(startMock).toHaveBeenCalledWith({ + eventDispatcher: expect.any(Object), + }); + expect((globalThis as { __feishuWsStarted?: boolean }).__feishuWsStarted).toBe(true); + }); +}); diff --git a/src/lib/bot/feishu/ws-client.test.ts b/src/lib/bot/feishu/ws-client.test.ts index 045c5615..3b206e6b 100644 --- a/src/lib/bot/feishu/ws-client.test.ts +++ b/src/lib/bot/feishu/ws-client.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { createSdkLogger } from "./ws-client"; +import { createSdkLogger, isConstructableWsClient } from "./ws-client"; beforeEach(() => { vi.restoreAllMocks(); @@ -101,3 +101,21 @@ describe("createSdkLogger", () => { logger.error("[ws]", "connect failed"); }); }); + +describe("isConstructableWsClient", () => { + it("accepts constructor functions", () => { + class FakeWsClient { + start() { + return Promise.resolve(); + } + } + + expect(isConstructableWsClient(FakeWsClient)).toBe(true); + }); + + it("rejects non-constructors", () => { + expect(isConstructableWsClient(undefined)).toBe(false); + expect(isConstructableWsClient({})).toBe(false); + expect(isConstructableWsClient(() => ({ start: async () => {} }))).toBe(false); + }); +}); diff --git a/src/lib/bot/feishu/ws-client.ts b/src/lib/bot/feishu/ws-client.ts index 69a14c51..c963797f 100644 --- a/src/lib/bot/feishu/ws-client.ts +++ b/src/lib/bot/feishu/ws-client.ts @@ -21,6 +21,19 @@ import { getFeishuConfig } from "../types"; import { createFeishuAdapter } from "./client"; import { handleFeishuMessage } from "./message-handler"; +type FeishuWsClientLike = { + close?: (options?: { force?: boolean }) => void; + start: (options: { eventDispatcher: unknown }) => Promise<unknown>; +}; + +type FeishuWsClientConstructor = new (options: { + appId: string; + appSecret: string; + domain: (typeof lark.Domain)[keyof typeof lark.Domain]; + loggerLevel: lark.LoggerLevel; + logger: ReturnType<typeof createSdkLogger>; +}) => FeishuWsClientLike; + // --------------------------------------------------------------------------- // Singleton guard (survives HMR in dev) // --------------------------------------------------------------------------- @@ -28,9 +41,28 @@ import { handleFeishuMessage } from "./message-handler"; // Use globalThis to prevent multiple WSClient instances during Next.js HMR const globalForFeishu = globalThis as unknown as { __feishuWsStarted?: boolean; - __feishuWsClient?: InstanceType<typeof lark.WSClient>; + __feishuWsClient?: FeishuWsClientLike; + __feishuWsUnsupported?: boolean; }; +export function isConstructableWsClient(value: unknown): value is FeishuWsClientConstructor { + if (typeof value !== "function") { + return false; + } + + try { + Reflect.construct(String, [], value as new () => unknown); + return true; + } catch { + return false; + } +} + +function resolveWsClientConstructor(): FeishuWsClientConstructor | null { + const candidate = (lark as unknown as { WSClient?: unknown }).WSClient; + return isConstructableWsClient(candidate) ? candidate : null; +} + // --------------------------------------------------------------------------- // SDK logger // --------------------------------------------------------------------------- @@ -106,7 +138,7 @@ export function createSdkLogger(callbacks: { */ function cleanupWsClient(reason: string, err?: unknown): void { try { - globalForFeishu.__feishuWsClient?.close({ force: true }); + globalForFeishu.__feishuWsClient?.close?.({ force: true }); } catch (e) { console.debug("[feishu-ws] Ignoring error during WSClient cleanup:", e); } @@ -135,6 +167,14 @@ export function startFeishuWSClient(): void { return; } + if (globalForFeishu.__feishuWsUnsupported) { + console.log( + "[feishu-ws] Installed @larksuiteoapi/node-sdk has no WSClient export; " + + "HTTP webhook mode remains available at /api/bot/feishu." + ); + return; + } + const config = getFeishuConfig(); if (!config.enabled || !config.appId || !config.appSecret) { @@ -184,8 +224,18 @@ export function startFeishuWSClient(): void { }, }); + const WsClient = resolveWsClientConstructor(); + if (!WsClient) { + globalForFeishu.__feishuWsUnsupported = true; + console.warn( + "[feishu-ws] Installed @larksuiteoapi/node-sdk does not export WSClient. " + + "Skipping Feishu WebSocket startup and keeping HTTP webhook mode enabled at /api/bot/feishu." + ); + return; + } + // Create WSClient for receiving events via WebSocket - const wsClient = new lark.WSClient({ + const wsClient = new WsClient({ appId: config.appId, appSecret: config.appSecret, domain: lark.Domain.Feishu, diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 0530ed47..61a5d521 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -4,6 +4,8 @@ import * as schema from "./schema"; import path from "path"; import fs from "fs"; +import { assessPathLocking } from "../dev/project-filesystem"; + // DATABASE_URL accepts plain filesystem paths only (not SQLite URI strings). // Strip a leading "file:" prefix if present so that path.resolve() works correctly. const rawDbUrl = process.env.DATABASE_URL?.replace(/^file:/, ""); @@ -20,29 +22,59 @@ const globalForDb = globalThis as unknown as { }; const sqlite = globalForDb.sqlite ?? new Database(DB_PATH); +const dbLocking = assessPathLocking(DB_PATH); if (process.env.NODE_ENV !== "production") { globalForDb.sqlite = sqlite; } -// WAL mode is faster but requires mmap support. -// Network/shared filesystems (NFS, CIFS, FUSE) often lack mmap support, -// causing SQLITE_IOERR_SHMMAP. Fall back to DELETE journal mode in that case. -try { - sqlite.pragma("journal_mode = WAL"); -} catch (err) { - // Only fall back for the specific mmap-related error; surface others. - if ( - err && - typeof err === "object" && - "code" in err && - (err as { code?: unknown }).code === "SQLITE_IOERR_SHMMAP" - ) { +function getSqliteErrorCode(err: unknown): string | undefined { + if (err && typeof err === "object" && "code" in err) { + return String((err as { code?: unknown }).code ?? ""); + } + + return undefined; +} + +function trySetDeleteJournalMode(): void { + try { sqlite.pragma("journal_mode = DELETE"); - } else { + } catch (err) { + if (getSqliteErrorCode(err) === "SQLITE_BUSY") { + console.warn( + "[db] SQLite journal mode stayed unchanged because the database was busy during startup. Use a local DATABASE_URL for reliable locking on network filesystems." + ); + return; + } + throw err; } } + +sqlite.pragma("busy_timeout = 5000"); + +// WAL mode is faster but requires mmap support. +// Network/shared filesystems (NFS, CIFS, FUSE) often lack mmap support, +// or reliable locking, causing SQLITE_IOERR_SHMMAP / SQLITE_BUSY. +if (dbLocking.disableLock) { + const mountPoint = dbLocking.mount?.mountPoint ?? path.dirname(DB_PATH); + console.warn( + `[db] Detected ${dbLocking.reason} at ${mountPoint}; using SQLite DELETE journal mode. Set DATABASE_URL to a local filesystem path for reliable locking.` + ); + trySetDeleteJournalMode(); +} else { + try { + sqlite.pragma("journal_mode = WAL"); + } catch (err) { + const code = getSqliteErrorCode(err); + + if (code === "SQLITE_IOERR_SHMMAP" || code === "SQLITE_BUSY") { + trySetDeleteJournalMode(); + } else { + throw err; + } + } +} sqlite.pragma("foreign_keys = ON"); export const db = drizzle(sqlite, { schema }); diff --git a/src/lib/db/migrate.ts b/src/lib/db/migrate.ts index 2691692f..2a068f58 100644 --- a/src/lib/db/migrate.ts +++ b/src/lib/db/migrate.ts @@ -5,6 +5,12 @@ import crypto from "crypto"; import fs from "fs"; const MIGRATIONS_TABLE = "__drizzle_migrations"; +const MIGRATION_RETRY_ATTEMPTS = 10; +const MIGRATION_RETRY_DELAY_MS = 500; + +const globalForMigrations = globalThis as typeof globalThis & { + __innoclawMigrationPromise?: Promise<void>; +}; function ensureMigrationsTable() { sqlite @@ -88,7 +94,21 @@ function runSqliteMigrations(migrationsFolder: string) { } } -export function runMigrations() { +function isSqliteBusy(error: unknown): boolean { + if ( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "SQLITE_BUSY" + ) { + return true; + } + + const message = error instanceof Error ? error.message : String(error); + return message.includes("database is locked"); +} + +function runMigrationsOnce() { const migrationsFolder = path.join(process.cwd(), "drizzle"); try { runSqliteMigrations(migrationsFolder); @@ -111,3 +131,38 @@ export function runMigrations() { } } } + +async function runMigrationsWithRetry() { + for (let attempt = 1; attempt <= MIGRATION_RETRY_ATTEMPTS; attempt += 1) { + try { + runMigrationsOnce(); + return; + } catch (error) { + if (!isSqliteBusy(error)) { + throw error; + } + + if (attempt === MIGRATION_RETRY_ATTEMPTS) { + console.warn( + "[migrate] Database remained busy during startup. Assuming another worker/process is handling migrations and continuing." + ); + return; + } + + await new Promise((resolve) => + setTimeout(resolve, MIGRATION_RETRY_DELAY_MS) + ); + } + } +} + +export function runMigrations(): Promise<void> { + if (!globalForMigrations.__innoclawMigrationPromise) { + globalForMigrations.__innoclawMigrationPromise = runMigrationsWithRetry(); + globalForMigrations.__innoclawMigrationPromise.catch(() => { + globalForMigrations.__innoclawMigrationPromise = undefined; + }); + } + + return globalForMigrations.__innoclawMigrationPromise; +} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 0f3041bc..2213c6ce 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -1,11 +1,46 @@ import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core"; import { sql } from "drizzle-orm"; +// ============================================================ +// USERS / SESSIONS +// ============================================================ +export const users = sqliteTable("users", { + id: text("id").primaryKey(), + email: text("email").notNull(), + name: text("name").notNull(), + passwordHash: text("password_hash").notNull(), + role: text("role", { enum: ["admin", "user"] }).notNull().default("user"), + isActive: integer("is_active", { mode: "boolean" }).notNull().default(true), + lastLoginAt: text("last_login_at"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), + updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`), +}, (table) => [ + uniqueIndex("users_email_unique_idx").on(table.email), +]); + +export const userSessions = sqliteTable("user_sessions", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: text("expires_at").notNull(), + lastSeenAt: text("last_seen_at"), + revokedAt: text("revoked_at"), + createdAt: text("created_at").notNull().default(sql`(datetime('now'))`), +}, (table) => [ + uniqueIndex("user_sessions_token_hash_unique_idx").on(table.tokenHash), + index("user_sessions_user_idx").on(table.userId), + index("user_sessions_expires_idx").on(table.expiresAt), +]); + // ============================================================ // WORKSPACES // ============================================================ export const workspaces = sqliteTable("workspaces", { id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .references(() => users.id, { onDelete: "set null" }), name: text("name").notNull(), folderPath: text("folder_path").notNull(), description: text("description"), @@ -121,6 +156,8 @@ export const appSettings = sqliteTable("app_settings", { // ============================================================ export const skills = sqliteTable("skills", { id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .references(() => users.id, { onDelete: "set null" }), workspaceId: text("workspace_id").references(() => workspaces.id, { onDelete: "cascade", }), // null = global skill @@ -149,6 +186,8 @@ export const skills = sqliteTable("skills", { // ============================================================ export const scheduledTasks = sqliteTable("scheduled_tasks", { id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .references(() => users.id, { onDelete: "set null" }), name: text("name").notNull(), taskType: text("task_type", { enum: ["daily_report", "weekly_report", "git_sync", "source_sync", "custom"], @@ -208,6 +247,8 @@ export const clusterOperations = sqliteTable("cluster_operations", { // ============================================================ export const hfDatasets = sqliteTable("hf_datasets", { id: text("id").primaryKey(), + ownerUserId: text("owner_user_id") + .references(() => users.id, { onDelete: "set null" }), name: text("name").notNull(), repoId: text("repo_id").notNull(), repoType: text("repo_type").notNull().default("dataset"), // dataset | model | space diff --git a/src/lib/db/skills-insert.ts b/src/lib/db/skills-insert.ts index ee9ad107..bd0b81ad 100644 --- a/src/lib/db/skills-insert.ts +++ b/src/lib/db/skills-insert.ts @@ -18,7 +18,8 @@ function stripWrappingQuotes(value: string | undefined | null): string | null { /** Import a single SkillExportData into the DB, returns the inserted skill id or null */ export async function insertSkill( data: SkillExportData, - workspaceId: string | null + workspaceId: string | null, + ownerUserId?: string | null ): Promise<string | null> { try { const normalizedSlug = slugify(data.slug); @@ -54,6 +55,7 @@ export async function insertSkill( await db.insert(skills).values({ id, + ownerUserId: ownerUserId ?? null, workspaceId: workspaceId || null, name: data.name, slug: finalSlug, diff --git a/src/lib/deep-research/actors/main-brain.ts b/src/lib/deep-research/actors/main-brain.ts deleted file mode 100644 index 4c06c6cf..00000000 --- a/src/lib/deep-research/actors/main-brain.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { - ActorExecutionContext, - ActorExecutionResult, - BrainDecision, - DeepResearchNode, - DeepResearchSession, - ModelRole, - RequirementState, -} from "../types"; -import { buildStructuredRoleReply, getStructuredRoleDefinition, RESEARCHER_ROLE_ID } from "../role-registry"; - -function interfaceOnlyMessage(): string { - return "Researcher is preserved as the main-brain interface placeholder in this branch; execution is disabled."; -} - -export interface MainBrainContract { - replyToNodeMessage( - session: DeepResearchSession, - node: DeepResearchNode, - userMessage: string, - options?: { - abortSignal?: AbortSignal; - contextNote?: string; - }, - ): Promise<{ message: string; tokensUsed: number }>; - decide( - session: DeepResearchSession, - options?: { - abortSignal?: AbortSignal; - requirementState?: RequirementState | null; - languageHint?: string; - }, - ): Promise<BrainDecision>; - executeNode( - node: DeepResearchNode, - ctx: ActorExecutionContext, - abortSignal?: AbortSignal, - ): Promise<ActorExecutionResult>; -} - -export class MainBrain implements MainBrainContract { - async replyToNodeMessage( - _session: DeepResearchSession, - node: DeepResearchNode, - userMessage: string, - ): Promise<{ message: string; tokensUsed: number }> { - const roleId = typeof node.input?.roleId === "string" - ? node.input.roleId as ModelRole - : RESEARCHER_ROLE_ID; - const role = getStructuredRoleDefinition(roleId); - return { - message: role ? buildStructuredRoleReply(role, userMessage) : interfaceOnlyMessage(), - tokensUsed: 0, - }; - } - - async decide(session: DeepResearchSession): Promise<BrainDecision> { - const role = getStructuredRoleDefinition(RESEARCHER_ROLE_ID); - return { - action: "respond_to_user", - messageToUser: role - ? buildStructuredRoleReply(role, `Coordinate the active research session "${session.title}".`) - : `${interfaceOnlyMessage()} Session: ${session.title}`, - }; - } - - async executeNode(node: DeepResearchNode): Promise<ActorExecutionResult> { - return { - output: { - interface: "main-brain", - nodeId: node.id, - status: "reserved", - message: interfaceOnlyMessage(), - }, - artifacts: [], - tokensUsed: 0, - }; - } -} - -export const mainBrainInterface: MainBrainContract = new MainBrain(); diff --git a/src/lib/deep-research/actors/workers.ts b/src/lib/deep-research/actors/workers.ts deleted file mode 100644 index d1f3fa71..00000000 --- a/src/lib/deep-research/actors/workers.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { - ActorExecutionContext, - ActorExecutionResult, - DeepResearchNode, - ModelRole, -} from "../types"; -import { buildStructuredRoleReply, getStructuredRoleDefinition } from "../role-registry"; - -function interfaceOnlyMessage(): string { - return "Meta-workers are preserved as interface placeholders in this branch; execution is disabled."; -} - -export interface MetaWorkerContract { - supports(node: DeepResearchNode): boolean; - execute( - node: DeepResearchNode, - ctx: ActorExecutionContext, - abortSignal?: AbortSignal, - ): Promise<ActorExecutionResult>; -} - -class InterfaceOnlyMetaWorker implements MetaWorkerContract { - supports(): boolean { - return true; - } - - async execute(node: DeepResearchNode): Promise<ActorExecutionResult> { - const roleId = typeof node.input?.roleId === "string" - ? node.input.roleId as ModelRole - : node.assignedRole; - const role = getStructuredRoleDefinition(roleId); - return { - output: { - interface: "meta-workers", - nodeId: node.id, - status: "reserved", - message: role - ? buildStructuredRoleReply(role, `Execute the ${role.roleName} contract for this session.`) - : interfaceOnlyMessage(), - }, - artifacts: [], - tokensUsed: 0, - }; - } -} - -export class WorkerRegistry { - private static readonly worker: MetaWorkerContract = new InterfaceOnlyMetaWorker(); - - static resolve(node: DeepResearchNode): MetaWorkerContract { - void node; - return this.worker; - } -} - -export const metaWorkersInterface: MetaWorkerContract = new InterfaceOnlyMetaWorker(); diff --git a/src/lib/deep-research/api-helpers.ts b/src/lib/deep-research/api-helpers.ts index 3f60b648..92401a6a 100644 --- a/src/lib/deep-research/api-helpers.ts +++ b/src/lib/deep-research/api-helpers.ts @@ -1,6 +1,10 @@ import { NextResponse } from "next/server"; import { getConfiguredModelSelection } from "@/lib/ai/provider"; import { getSession, updateSession } from "./event-store"; +import { + buildDeepResearchConfigForResolvedModel, + hasDeepResearchModelConfigDrift, +} from "./model-overrides"; import type { DeepResearchSession } from "./types"; export class DeepResearchApiError extends Error { @@ -35,21 +39,16 @@ export async function requireSession(sessionId: string): Promise<DeepResearchSes if (session.config.interfaceOnly !== true) { const configuredModel = await getConfiguredModelSelection(); - const needsModelSync = - session.config.resolvedModel?.provider !== configuredModel.providerId - || session.config.resolvedModel?.modelId !== configuredModel.modelId - || session.config.modelOverrides !== undefined; + const resolvedModel = { + provider: configuredModel.providerId, + modelId: configuredModel.modelId, + }; + const nextConfig = buildDeepResearchConfigForResolvedModel(session.config, resolvedModel); + const needsModelSync = hasDeepResearchModelConfigDrift(session.config, nextConfig); if (needsModelSync) { await updateSession(sessionId, { - config: { - ...session.config, - resolvedModel: { - provider: configuredModel.providerId, - modelId: configuredModel.modelId, - }, - modelOverrides: undefined, - }, + config: nextConfig, }); session = await getSession(sessionId); if (!session) { diff --git a/src/lib/deep-research/artifact-references.test.ts b/src/lib/deep-research/artifact-references.test.ts new file mode 100644 index 00000000..da8b2d10 --- /dev/null +++ b/src/lib/deep-research/artifact-references.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeArtifactReferenceFields, + extractArtifactReferenceTokens, + resolveArtifactReferenceIds, +} from "./artifact-references"; +import type { DeepResearchArtifact } from "./types"; + +function createArtifact(overrides: Partial<DeepResearchArtifact>): DeepResearchArtifact { + return { + id: overrides.id ?? "artifact-1", + sessionId: overrides.sessionId ?? "session-1", + nodeId: overrides.nodeId ?? "node-1", + artifactType: overrides.artifactType ?? "evidence_card", + title: overrides.title ?? "Artifact", + content: overrides.content ?? {}, + provenance: overrides.provenance ?? null, + version: overrides.version ?? 1, + createdAt: overrides.createdAt ?? "2026-04-16T00:00:00.000Z", + }; +} + +describe("artifact references", () => { + const artifacts = [ + createArtifact({ + id: "CabdyeKdSgc6Uxna5SXZx", + title: "Evidence: 稀疏注意力与反向嵌入", + }), + createArtifact({ + id: "hYn9hjPGojOJdWG3S1Y4V", + artifactType: "structured_summary", + title: "Summary: 时间序列Transformer综述证据综合与结构化学术内容构建", + }), + ]; + + it("extracts deduplicated artifact reference tokens from common input fields", () => { + const tokens = extractArtifactReferenceTokens({ + targetArtifactIds: ["CabdyeKd", "CabdyeKdSgc6Uxna5SXZx"], + sourceArtifactIds: ["hYn9hjPG"], + }); + + expect(tokens).toEqual(["CabdyeKd", "CabdyeKdSgc6Uxna5SXZx", "hYn9hjPG"]); + }); + + it("resolves short artifact ids to canonical ids", () => { + const resolved = resolveArtifactReferenceIds( + { + targetArtifactIds: ["CabdyeKd", "hYn9hjPG"], + }, + artifacts, + ); + + expect(resolved).toEqual([ + "CabdyeKdSgc6Uxna5SXZx", + "hYn9hjPGojOJdWG3S1Y4V", + ]); + }); + + it("canonicalizes artifact reference fields in node input", () => { + const normalized = canonicalizeArtifactReferenceFields( + { + targetArtifactIds: ["CabdyeKd", "CabdyeKdSgc6Uxna5SXZx"], + sourceArtifactIds: ["hYn9hjPG"], + }, + artifacts, + ); + + expect(normalized).toMatchObject({ + targetArtifactIds: ["CabdyeKdSgc6Uxna5SXZx"], + sourceArtifactIds: ["hYn9hjPGojOJdWG3S1Y4V"], + }); + }); +}); diff --git a/src/lib/deep-research/artifact-references.ts b/src/lib/deep-research/artifact-references.ts new file mode 100644 index 00000000..1091f0f3 --- /dev/null +++ b/src/lib/deep-research/artifact-references.ts @@ -0,0 +1,136 @@ +import type { DeepResearchArtifact } from "./types"; + +const ARTIFACT_REFERENCE_INPUT_KEYS = [ + "targetArtifactIds", + "sourceArtifactIds", + "requiredEvidenceArtifactIds", + "blueprintArtifactIds", + "artifactIds", +] as const; + +function normalizeReferenceToken(value: string): string { + return value + .trim() + .replace(/^[\[\(\{"'`]+/, "") + .replace(/[\]\)\}"'`,。;、,:;]+$/, "") + .trim(); +} + +function normalizeAlias(value: string): string { + return value.trim().toLowerCase().replace(/\s+/g, " "); +} + +function resolveArtifactReferenceId( + reference: string, + artifacts: Pick<DeepResearchArtifact, "id" | "title">[], +): string | null { + const token = normalizeReferenceToken(reference); + if (!token) { + return null; + } + + const exactIdMatch = artifacts.find((artifact) => artifact.id === token); + if (exactIdMatch) { + return exactIdMatch.id; + } + + const exactTitleMatches = artifacts.filter((artifact) => normalizeAlias(artifact.title) === normalizeAlias(token)); + if (exactTitleMatches.length === 1) { + return exactTitleMatches[0].id; + } + + const prefixMatches = artifacts.filter((artifact) => artifact.id.startsWith(token)); + if (prefixMatches.length === 1) { + return prefixMatches[0].id; + } + + return null; +} + +export function extractArtifactReferenceTokens( + input: Record<string, unknown> | null | undefined, +): string[] { + if (!input) { + return []; + } + + const collected: string[] = []; + for (const key of ARTIFACT_REFERENCE_INPUT_KEYS) { + const value = input[key]; + if (!Array.isArray(value)) { + continue; + } + + for (const item of value) { + if (typeof item !== "string") { + continue; + } + const token = normalizeReferenceToken(item); + if (token) { + collected.push(token); + } + } + } + + return [...new Set(collected)]; +} + +export function resolveArtifactReferenceIds( + input: Record<string, unknown> | null | undefined, + artifacts: Pick<DeepResearchArtifact, "id" | "title">[], +): string[] { + const resolvedIds: string[] = []; + const seen = new Set<string>(); + + for (const token of extractArtifactReferenceTokens(input)) { + const resolved = resolveArtifactReferenceId(token, artifacts); + if (!resolved || seen.has(resolved)) { + continue; + } + seen.add(resolved); + resolvedIds.push(resolved); + } + + return resolvedIds; +} + +export function canonicalizeArtifactReferenceFields( + input: Record<string, unknown> | null | undefined, + artifacts: Pick<DeepResearchArtifact, "id" | "title">[], +): Record<string, unknown> | undefined { + if (!input) { + return undefined; + } + + const nextInput: Record<string, unknown> = { ...input }; + + for (const key of ARTIFACT_REFERENCE_INPUT_KEYS) { + const value = input[key]; + if (!Array.isArray(value)) { + continue; + } + + const normalizedValues: string[] = []; + const seen = new Set<string>(); + + for (const item of value) { + if (typeof item !== "string") { + continue; + } + const token = normalizeReferenceToken(item); + if (!token) { + continue; + } + const resolved = resolveArtifactReferenceId(token, artifacts) ?? token; + if (seen.has(resolved)) { + continue; + } + seen.add(resolved); + normalizedValues.push(resolved); + } + + nextInput[key] = normalizedValues; + } + + return nextInput; +} diff --git a/src/lib/deep-research/checkpoint-policy.test.ts b/src/lib/deep-research/checkpoint-policy.test.ts new file mode 100644 index 00000000..68d4de1e --- /dev/null +++ b/src/lib/deep-research/checkpoint-policy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + shouldPauseAfterCompletedNode, + shouldPauseAfterResearcherStep, +} from "./checkpoint-policy"; + +describe("checkpoint-policy", () => { + it("keeps the initial plan checkpoint before any worker dispatch", () => { + expect(shouldPauseAfterResearcherStep({ + interactionMode: "confirmation", + requiresInitialPlanConfirmation: true, + plannedNodeCount: 1, + })).toBe(true); + }); + + it("keeps clarification checkpoints when user input is required", () => { + expect(shouldPauseAfterResearcherStep({ + interactionMode: "answer_required", + requiresInitialPlanConfirmation: false, + plannedNodeCount: 0, + })).toBe(true); + }); + + it("auto-continues after planning when concrete work is queued", () => { + expect(shouldPauseAfterResearcherStep({ + interactionMode: "confirmation", + requiresInitialPlanConfirmation: false, + plannedNodeCount: 1, + })).toBe(false); + }); + + it("still pauses if the researcher has no concrete next task to run", () => { + expect(shouldPauseAfterResearcherStep({ + interactionMode: "confirmation", + requiresInitialPlanConfirmation: false, + plannedNodeCount: 0, + })).toBe(true); + }); + + it("keeps the final report checkpoint", () => { + expect(shouldPauseAfterCompletedNode({ isFinalStep: true })).toBe(true); + expect(shouldPauseAfterResearcherStep({ + interactionMode: "confirmation", + isFinalStep: true, + requiresInitialPlanConfirmation: false, + plannedNodeCount: 0, + })).toBe(true); + }); +}); diff --git a/src/lib/deep-research/checkpoint-policy.ts b/src/lib/deep-research/checkpoint-policy.ts new file mode 100644 index 00000000..6fe0a613 --- /dev/null +++ b/src/lib/deep-research/checkpoint-policy.ts @@ -0,0 +1,28 @@ +import type { CheckpointInteractionMode } from "./types"; + +export function shouldPauseAfterCompletedNode(input: { + isFinalStep?: boolean; +}): boolean { + return Boolean(input.isFinalStep); +} + +export function shouldPauseAfterResearcherStep(input: { + interactionMode: CheckpointInteractionMode; + isFinalStep?: boolean; + requiresInitialPlanConfirmation: boolean; + plannedNodeCount: number; +}): boolean { + if (input.isFinalStep) { + return true; + } + + if (input.requiresInitialPlanConfirmation) { + return true; + } + + if (input.interactionMode === "answer_required") { + return true; + } + + return input.plannedNodeCount === 0; +} diff --git a/src/lib/deep-research/checkpoint-runtime.ts b/src/lib/deep-research/checkpoint-runtime.ts new file mode 100644 index 00000000..69bb20cd --- /dev/null +++ b/src/lib/deep-research/checkpoint-runtime.ts @@ -0,0 +1,254 @@ +import { getStructuredPromptForNode, getStructuredRoleDisplayName } from "./role-registry"; +import type { + ContextTag, + DeepResearchArtifact, + DeepResearchNode, + MainBrainAudit, + NodeCreationSpec, + StructuredPromptKind, +} from "./types"; + +type RecommendedDispatch = { + roleId: NodeCreationSpec["assignedRole"]; + roleName: string; + nodeType: NodeCreationSpec["nodeType"]; + label: string; + promptUsed?: { + title: string; + kind: StructuredPromptKind; + objective: string; + }; +}; + +export function getRecommendedDispatch( + freshNodes: DeepResearchNode[], + plannedSpecs: NodeCreationSpec[], +): RecommendedDispatch | null { + const pendingNode = freshNodes + .filter((node) => node.status === "pending" || node.status === "queued") + .sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0]; + + const candidate = pendingNode + ? { + assignedRole: pendingNode.assignedRole, + nodeType: pendingNode.nodeType, + label: pendingNode.label, + } + : plannedSpecs[0]; + + if (!candidate) { + return null; + } + + const prompt = getStructuredPromptForNode(candidate.assignedRole, candidate.nodeType); + return { + roleId: candidate.assignedRole, + roleName: getStructuredRoleDisplayName(candidate.assignedRole, candidate.nodeType), + nodeType: candidate.nodeType, + label: candidate.label, + promptUsed: prompt + ? { + title: prompt.title, + kind: prompt.kind, + objective: prompt.objective, + } + : undefined, + }; +} + +export function getCheckpointReviewArtifacts( + contextTag: ContextTag, + completedNode: DeepResearchNode, + nodes: DeepResearchNode[], + artifacts: DeepResearchArtifact[], +): DeepResearchArtifact[] { + if (!isLiteratureExecutionContext(contextTag, completedNode, nodes)) { + return artifacts.filter((artifact) => artifact.nodeId === completedNode.id); + } + + const relevantNodeIds = new Set( + nodes + .filter((node) => + node.nodeType === "evidence_gather" && + node.contextTag === contextTag && + ["completed", "failed", "skipped"].includes(node.status), + ) + .map((node) => node.id), + ); + + const evidenceArtifacts = artifacts.filter((artifact) => + artifact.artifactType === "evidence_card" && + Boolean(artifact.nodeId) && + relevantNodeIds.has(artifact.nodeId as string), + ); + + return evidenceArtifacts.length > 0 + ? evidenceArtifacts + : artifacts.filter((artifact) => artifact.nodeId === completedNode.id); +} + +export function getEvidencePhaseSummary( + contextTag: ContextTag, + nodes: DeepResearchNode[], + artifacts: DeepResearchArtifact[], +): { + papersCollected: number; + retrievalTaskCount: number; + successfulTaskCount: number; + failedTaskCount: number; + emptyTaskCount: number; +} | null { + const relevantCompletedNode = nodes.find((node) => + node.contextTag === contextTag && + ["completed", "failed", "skipped"].includes(node.status), + ); + if (!isLiteratureExecutionContext(contextTag, relevantCompletedNode, nodes)) { + return null; + } + + const relevantNodes = nodes.filter((node) => + node.nodeType === "evidence_gather" && + node.contextTag === contextTag && + ["completed", "failed", "skipped"].includes(node.status), + ); + + const artifactByNodeId = new Map( + artifacts + .filter((artifact) => artifact.artifactType === "evidence_card" && artifact.nodeId) + .map((artifact) => [artifact.nodeId as string, artifact]), + ); + + let successfulTaskCount = 0; + let failedTaskCount = 0; + let emptyTaskCount = 0; + + for (const node of relevantNodes) { + if (node.status === "failed") { + failedTaskCount += 1; + continue; + } + + const artifact = artifactByNodeId.get(node.id); + const sources = Array.isArray(artifact?.content.sources) ? artifact.content.sources : []; + const totalFound = typeof artifact?.content.totalFound === "number" + ? artifact.content.totalFound + : typeof artifact?.content.papersFound === "number" + ? artifact.content.papersFound + : sources.length; + + if (Math.max(totalFound, sources.length) > 0) { + successfulTaskCount += 1; + } else { + emptyTaskCount += 1; + } + } + + return { + papersCollected: aggregateSourceCount(artifacts), + retrievalTaskCount: relevantNodes.length, + successfulTaskCount, + failedTaskCount, + emptyTaskCount, + }; +} + +export function applyFinalReportCheckpointGuard( + checkpointContent: { + title?: string; + humanSummary?: string; + machineSummary?: string; + mainBrainAudit?: MainBrainAudit; + currentFindings?: string; + openQuestions?: string[]; + recommendedNextAction?: string; + continueWillDo?: string; + alternativeNextActions?: string[]; + }, + preferredOutputLanguage: string, +): { + title?: string; + humanSummary?: string; + machineSummary?: string; + mainBrainAudit?: MainBrainAudit; + currentFindings?: string; + openQuestions?: string[]; + recommendedNextAction?: string; + continueWillDo?: string; + alternativeNextActions?: string[]; +} { + const copy = getFinalReportCheckpointCopy(preferredOutputLanguage); + return { + ...checkpointContent, + recommendedNextAction: copy.recommendedNextAction, + continueWillDo: copy.continueWillDo, + alternativeNextActions: copy.alternativeNextActions, + mainBrainAudit: checkpointContent.mainBrainAudit + ? { + ...checkpointContent.mainBrainAudit, + recommendedNextAction: copy.recommendedNextAction, + continueWillDo: copy.continueWillDo, + alternativeActions: checkpointContent.mainBrainAudit.alternativeActions.filter( + (action) => action.actionType !== "more_literature", + ), + } + : undefined, + }; +} + +export function getFinalReportCheckpointCopy(preferredOutputLanguage: string): { + recommendedNextAction: string; + continueWillDo: string; + alternativeNextActions: string[]; +} { + if (preferredOutputLanguage.startsWith("zh")) { + return { + recommendedNextAction: "请审阅最终报告,并选择接受为本次研究结论,或提出定向修改意见;不要回退到早期的大范围文献检索轮次。", + continueWillDo: "继续将把这份最终报告作为当前研究交付物并结束本次会话;如果你希望补充内容,请选择修订并指出需要补充的具体证据或段落。", + alternativeNextActions: [ + "接受最终报告并结束本次研究", + "要求定向修订最终报告中的具体段落、论证或证据", + ], + }; + } + + return { + recommendedNextAction: "Review the final report and either accept it as the session outcome or request targeted revisions; do not restart broad literature rounds from earlier phases.", + continueWillDo: "Continue will finalize this report as the current research deliverable and close the session unless you request targeted revisions.", + alternativeNextActions: [ + "Accept the final report and close the session", + "Request targeted revisions to specific sections, claims, or supporting evidence", + ], + }; +} + +function aggregateSourceCount(artifacts: DeepResearchArtifact[]): number { + return artifacts.reduce((sum, artifact) => { + const sources = Array.isArray(artifact.content.sources) ? artifact.content.sources : []; + const totalFound = typeof artifact.content.totalFound === "number" + ? artifact.content.totalFound + : typeof artifact.content.papersFound === "number" + ? artifact.content.papersFound + : sources.length; + return sum + Math.max(totalFound, sources.length); + }, 0); +} + +function isLiteratureExecutionContext( + contextTag: ContextTag, + completedNode: DeepResearchNode | undefined, + nodes: DeepResearchNode[], +): boolean { + if (contextTag !== "planning") { + return false; + } + + if (completedNode?.nodeType === "evidence_gather") { + return true; + } + + return nodes.some((node) => + node.nodeType === "evidence_gather" && + node.contextTag === "planning" && + ["completed", "failed", "skipped"].includes(node.status), + ); +} diff --git a/src/lib/deep-research/config-types.ts b/src/lib/deep-research/config-types.ts new file mode 100644 index 00000000..e0858dbe --- /dev/null +++ b/src/lib/deep-research/config-types.ts @@ -0,0 +1,98 @@ +import type { ModelRole } from "./status-types"; + +export interface BudgetLimits { + maxTotalTokens: number; + maxOpusTokens: number; +} + +export interface BudgetUsage { + totalTokens: number; + opusTokens: number; + byRole: Partial<Record<ModelRole, number>>; + byNode: Record<string, number>; +} + +export type LauncherType = "rlaunch" | "rjob" | "slurm" | "local_shell" | "ssh"; + +export interface MountSpec { + source: string; + target: string; +} + +export interface ResourceProfile { + gpu: number; + memoryMb: number; + cpu: number; + privateMachine: "yes" | "no" | "group"; + maxWaitDuration?: string; +} + +export interface LiteratureConfig { + maxLiteratureRounds: number; + maxPapersPerRound: number; + maxTotalPapers: number; + maxReviewerRequestedExpansionRounds: number; + maxSearchRetries: number; +} + +export interface ExecutionConfig { + defaultLauncherType: LauncherType; + defaultResources: ResourceProfile; + defaultMounts: MountSpec[]; + defaultChargedGroup: string; +} + +export interface DeepResearchConfig { + modelOverrides?: Partial<Record<ModelRole, { provider: string; modelId: string }>>; + resolvedModel?: { provider: string; modelId: string }; + interfaceOnly?: boolean; + budget: BudgetLimits; + maxWorkerFanOut: number; + maxReviewerRounds: number; + maxExecutionLoops: number; + maxWorkerConcurrency: number; + literature: LiteratureConfig; + execution: ExecutionConfig; + skillRouting?: { enabled: boolean }; +} + +export const DEFAULT_LITERATURE_CONFIG: LiteratureConfig = { + maxLiteratureRounds: 3, + maxPapersPerRound: 10, + maxTotalPapers: 30, + maxReviewerRequestedExpansionRounds: 1, + maxSearchRetries: 2, +}; + +export const DEFAULT_EXECUTION_CONFIG: ExecutionConfig = { + defaultLauncherType: "rjob", + defaultResources: { + gpu: 2, + memoryMb: 200000, + cpu: 32, + privateMachine: "yes", + }, + defaultMounts: [ + { source: "gpfs://gpfs1/suencheng", target: "/mnt/shared-storage-user/suencheng" }, + { source: "gpfs://gpfs1/ai4sreason", target: "/mnt/shared-storage-user/ai4sreason" }, + ], + defaultChargedGroup: "ai4sdata_gpu", +}; + +export const DEFAULT_CONFIG: DeepResearchConfig = { + interfaceOnly: false, + budget: { + maxTotalTokens: 2_000_000, + maxOpusTokens: 500_000, + }, + maxWorkerFanOut: 1, + maxReviewerRounds: 2, + maxExecutionLoops: 3, + maxWorkerConcurrency: 1, + literature: DEFAULT_LITERATURE_CONFIG, + execution: DEFAULT_EXECUTION_CONFIG, +}; + +export function createEmptyUsage(): BudgetUsage { + return { totalTokens: 0, opusTokens: 0, byRole: {}, byNode: {} }; +} diff --git a/src/lib/deep-research/context-archive.test.ts b/src/lib/deep-research/context-archive.test.ts new file mode 100644 index 00000000..2d6db6c7 --- /dev/null +++ b/src/lib/deep-research/context-archive.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; +import { + buildArchiveSourceFingerprint, + formatResearchContextArchivePromptBlock, + retrieveResearchContextArchive, + splitContextTextIntoExcerpts, + type ResearchContextArchiveManifest, +} from "./context-archive"; + +describe("context-archive", () => { + it("splits long context into bounded excerpts", () => { + const text = [ + "# Overview", + "", + "Sparse attention improves long-range efficiency in Transformers by reducing quadratic attention cost.", + "", + "## Benchmarks", + "", + "Long-horizon forecasting benchmarks commonly include ETT, Electricity, Exchange, Traffic, and Weather.", + ].join("\n"); + + const excerpts = splitContextTextIntoExcerpts(text, 60); + expect(excerpts.length).toBeGreaterThan(2); + expect(excerpts[0]?.heading).toBe("Overview"); + expect(excerpts.every((excerpt) => excerpt.text.length <= 60)).toBe(true); + }); + + it("retrieves the most relevant persisted excerpts for a query", () => { + const manifest: ResearchContextArchiveManifest = { + manifestKind: "context_archive", + sessionId: "session-1", + generatedAt: "2026-04-16T00:00:00.000Z", + archiveDir: "/tmp/deep-research-memory/session-1", + fileCount: 2, + sourceFingerprint: "fingerprint", + records: [ + { + id: "artifact-a", + title: "Sparse Attention Survey", + category: "artifact", + artifactId: "artifact-a", + artifactType: "structured_summary", + nodeId: "node-a", + createdAt: "2026-04-16T00:00:00.000Z", + updatedAt: "2026-04-16T00:00:00.000Z", + importance: 0.9, + summary: "Compares sparse attention mechanisms and their scaling properties.", + keywords: ["sparse", "attention", "long", "sequence"], + charCount: 600, + filePath: "/tmp/deep-research-memory/session-1/artifact-a.md", + excerptCount: 1, + excerpts: [ + { + id: "artifact-a:excerpt:1", + title: "Sparse Attention Survey", + heading: "Mechanisms", + text: "Sparse attention reduces quadratic complexity and is central to long-context Transformer efficiency.", + keywords: ["sparse", "attention", "transformer", "long", "context"], + charCount: 98, + }, + ], + }, + { + id: "artifact-b", + title: "Benchmark Dataset Notes", + category: "artifact", + artifactId: "artifact-b", + artifactType: "evidence_card", + nodeId: "node-b", + createdAt: "2026-04-16T00:00:00.000Z", + updatedAt: "2026-04-16T00:00:00.000Z", + importance: 0.7, + summary: "Lists time-series forecasting datasets and metrics.", + keywords: ["dataset", "benchmark", "ett", "weather"], + charCount: 480, + filePath: "/tmp/deep-research-memory/session-1/artifact-b.md", + excerptCount: 1, + excerpts: [ + { + id: "artifact-b:excerpt:1", + title: "Benchmark Dataset Notes", + heading: "Datasets", + text: "Representative benchmarks include ETT, Electricity, Exchange, Traffic, and Weather.", + keywords: ["ett", "electricity", "traffic", "weather", "dataset"], + charCount: 86, + }, + ], + }, + ], + }; + + const retrieved = retrieveResearchContextArchive(manifest, "sparse attention for long context", 1); + expect(retrieved).toHaveLength(1); + expect(retrieved[0]?.title).toBe("Sparse Attention Survey"); + expect(retrieved[0]?.filePath).toContain("artifact-a.md"); + }); + + it("formats a compact archive prompt block with file references", () => { + const prompt = formatResearchContextArchivePromptBlock({ + query: "benchmark datasets", + archiveDir: "/tmp/deep-research-memory/session-1", + maxChars: 500, + excerpts: [ + { + id: "artifact-b:excerpt:1", + title: "Benchmark Dataset Notes", + heading: "Datasets", + text: "Representative benchmarks include ETT, Electricity, Exchange, Traffic, and Weather.", + keywords: ["ett", "electricity", "traffic", "weather", "dataset"], + charCount: 86, + filePath: "/tmp/deep-research-memory/session-1/artifact-b.md", + artifactId: "artifact-b", + artifactType: "evidence_card", + nodeId: "node-b", + summary: "Lists time-series forecasting datasets and metrics.", + retrievalScore: 9.2, + }, + ], + }); + + expect(prompt).toContain("Persisted Context Archive"); + expect(prompt).toContain("artifact-b.md"); + expect(prompt).toContain("benchmark datasets"); + }); + + it("creates stable source fingerprints from archived documents", () => { + const fingerprint = buildArchiveSourceFingerprint([ + { + id: "artifact-a", + title: "Sparse Attention Survey", + category: "artifact", + artifactId: "artifact-a", + artifactType: "structured_summary", + nodeId: "node-a", + createdAt: "2026-04-16T00:00:00.000Z", + updatedAt: "2026-04-16T00:00:00.000Z", + importance: 0.9, + content: "Sparse attention summary", + summary: "Sparse attention summary", + keywords: ["sparse", "attention"], + }, + ]); + + expect(fingerprint).toContain("artifact-a"); + expect(fingerprint).toContain("2026-04-16T00:00:00.000Z"); + }); +}); diff --git a/src/lib/deep-research/context-archive.ts b/src/lib/deep-research/context-archive.ts new file mode 100644 index 00000000..0729333d --- /dev/null +++ b/src/lib/deep-research/context-archive.ts @@ -0,0 +1,736 @@ +import path from "path"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { workspaces } from "@/lib/db/schema"; +import { writeFile } from "@/lib/files/filesystem"; +import * as store from "./event-store"; +import type { + CheckpointPackage, + ClaimMap, + DeepResearchArtifact, + DeepResearchMessage, + DeepResearchSession, + ReviewAssessment, +} from "./types"; + +const ARCHIVE_DIR_NAME = "deep-research-memory"; +const ARCHIVE_MANIFEST_KIND = "context_archive"; +const ARCHIVE_SOURCE_CHAR_THRESHOLD = 12_000; +const ARCHIVE_SOURCE_COUNT_THRESHOLD = 10; +const MAX_ARCHIVE_SOURCE_FILES = 18; +const MAX_ARCHIVE_SOURCE_CHARS = 14_000; +const MAX_ARCHIVE_EXCERPTS_PER_FILE = 6; +const DEFAULT_EXCERPT_CHAR_LIMIT = 900; +const DEFAULT_RETRIEVAL_TOP_K = 6; +const DEFAULT_PROMPT_BLOCK_CHAR_LIMIT = 2_800; + +interface ArchiveSourceDocument { + id: string; + title: string; + category: "artifact" | "message"; + artifactId?: string; + artifactType?: DeepResearchArtifact["artifactType"]; + nodeId?: string | null; + messageId?: string; + createdAt: string; + updatedAt: string; + importance: number; + content: string; + summary: string; + keywords: string[]; +} + +export interface ResearchContextArchiveExcerpt { + id: string; + title: string; + heading: string; + text: string; + keywords: string[]; + charCount: number; +} + +export interface ResearchContextArchiveRecord { + id: string; + title: string; + category: "artifact" | "message"; + artifactId?: string; + artifactType?: DeepResearchArtifact["artifactType"]; + nodeId?: string | null; + messageId?: string; + createdAt: string; + updatedAt: string; + importance: number; + summary: string; + keywords: string[]; + charCount: number; + filePath: string; + excerptCount: number; + excerpts: ResearchContextArchiveExcerpt[]; +} + +export interface ResearchContextArchiveManifest { + manifestKind: "context_archive"; + sessionId: string; + generatedAt: string; + archiveDir: string; + fileCount: number; + sourceFingerprint: string; + records: ResearchContextArchiveRecord[]; +} + +export interface RetrievedArchiveExcerpt extends ResearchContextArchiveExcerpt { + filePath: string; + artifactId?: string; + artifactType?: DeepResearchArtifact["artifactType"]; + nodeId?: string | null; + messageId?: string; + summary: string; + retrievalScore: number; +} + +export async function buildResearchContextArchivePromptBlock(input: { + session: DeepResearchSession; + messages: DeepResearchMessage[]; + artifacts: DeepResearchArtifact[]; + query: string; + topK?: number; + maxChars?: number; +}): Promise<string | null> { + const manifest = await ensureResearchContextArchive(input); + if (!manifest) { + return null; + } + + const retrieved = retrieveResearchContextArchive( + manifest, + input.query, + input.topK ?? DEFAULT_RETRIEVAL_TOP_K, + ); + if (retrieved.length === 0) { + return null; + } + + return formatResearchContextArchivePromptBlock({ + query: input.query, + archiveDir: manifest.archiveDir, + excerpts: retrieved, + maxChars: input.maxChars ?? DEFAULT_PROMPT_BLOCK_CHAR_LIMIT, + }); +} + +export async function ensureResearchContextArchive(input: { + session: DeepResearchSession; + messages: DeepResearchMessage[]; + artifacts: DeepResearchArtifact[]; +}): Promise<ResearchContextArchiveManifest | null> { + const sourceDocs = buildArchiveSourceDocuments(input.messages, input.artifacts); + if (!shouldPersistContextArchive(sourceDocs)) { + return null; + } + + const existingManifest = getLatestContextArchiveManifest(input.artifacts); + const sourceFingerprint = buildArchiveSourceFingerprint(sourceDocs); + if (existingManifest?.sourceFingerprint === sourceFingerprint) { + return existingManifest; + } + + const workspace = await resolveWorkspace(input.session.workspaceId); + if (!workspace) { + return null; + } + + const archiveDir = path.join(workspace.folderPath, ARCHIVE_DIR_NAME, input.session.id); + const records = sourceDocs.map((source) => { + const safeTitle = slugifyFileName(source.title || source.id); + const filePath = path.join(archiveDir, `${source.id}-${safeTitle}.md`); + const excerpts = splitContextTextIntoExcerpts(source.content, DEFAULT_EXCERPT_CHAR_LIMIT) + .slice(0, MAX_ARCHIVE_EXCERPTS_PER_FILE) + .map((excerpt, index) => ({ + id: `${source.id}:excerpt:${index + 1}`, + title: source.title, + heading: excerpt.heading, + text: excerpt.text, + keywords: dedupeStrings([ + ...source.keywords, + ...extractKeywords(excerpt.heading), + ...extractKeywords(excerpt.text), + ]).slice(0, 18), + charCount: excerpt.text.length, + })); + + return { + id: source.id, + title: source.title, + category: source.category, + artifactId: source.artifactId, + artifactType: source.artifactType, + nodeId: source.nodeId, + messageId: source.messageId, + createdAt: source.createdAt, + updatedAt: source.updatedAt, + importance: source.importance, + summary: source.summary, + keywords: source.keywords, + charCount: source.content.length, + filePath, + excerptCount: excerpts.length, + excerpts, + } satisfies ResearchContextArchiveRecord; + }); + + for (const source of sourceDocs) { + const record = records.find((candidate) => candidate.id === source.id); + if (!record) continue; + await writeFile(record.filePath, buildArchiveMarkdown(source, record)); + } + + const manifest: ResearchContextArchiveManifest = { + manifestKind: ARCHIVE_MANIFEST_KIND, + sessionId: input.session.id, + generatedAt: new Date().toISOString(), + archiveDir, + fileCount: records.length, + sourceFingerprint, + records, + }; + + await store.createArtifact( + input.session.id, + null, + "data_manifest", + `Context Archive (${records.length} files)`, + manifest as unknown as Record<string, unknown>, + ); + + return manifest; +} + +export function retrieveResearchContextArchive( + manifest: ResearchContextArchiveManifest, + query: string, + topK = DEFAULT_RETRIEVAL_TOP_K, +): RetrievedArchiveExcerpt[] { + const scored = manifest.records.flatMap((record) => + record.excerpts.map((excerpt) => ({ + ...excerpt, + filePath: record.filePath, + artifactId: record.artifactId, + artifactType: record.artifactType, + nodeId: record.nodeId, + messageId: record.messageId, + summary: record.summary, + retrievalScore: scoreArchiveExcerpt(record, excerpt, query), + })), + ); + + return scored + .filter((item) => item.retrievalScore > 0) + .sort((left, right) => right.retrievalScore - left.retrievalScore) + .slice(0, topK); +} + +export function formatResearchContextArchivePromptBlock(input: { + query: string; + archiveDir: string; + excerpts: RetrievedArchiveExcerpt[]; + maxChars?: number; +}): string | null { + if (input.excerpts.length === 0) { + return null; + } + + const lines: string[] = [ + "## Persisted Context Archive", + `- Overflow-safe session memory was persisted to workspace files under: ${input.archiveDir}`, + `- Retrieved archive excerpts for "${input.query}":`, + ]; + + for (const excerpt of input.excerpts) { + lines.push( + `- ${excerpt.title} [${excerpt.artifactType ?? "message"}]`, + ` file=${excerpt.filePath}`, + ` summary=${excerpt.summary}`, + ` excerpt(${excerpt.heading})=${excerpt.text}`, + ); + } + + return truncatePromptBlock(lines.join("\n"), input.maxChars ?? DEFAULT_PROMPT_BLOCK_CHAR_LIMIT); +} + +export function splitContextTextIntoExcerpts( + text: string, + maxChars = DEFAULT_EXCERPT_CHAR_LIMIT, +): Array<{ heading: string; text: string }> { + const normalized = text.replace(/\r\n/g, "\n").trim(); + if (!normalized) { + return []; + } + + const sections = normalized + .split(/\n{2,}/) + .map((section) => section.trim()) + .filter((section) => section.length > 0); + + const excerpts: Array<{ heading: string; text: string }> = []; + let currentHeading = "context"; + + for (const section of sections) { + if (/^#{1,6}\s+/.test(section)) { + currentHeading = section.replace(/^#{1,6}\s+/, "").trim(); + } + + if (section.length <= maxChars) { + excerpts.push({ + heading: currentHeading, + text: section, + }); + continue; + } + + let cursor = 0; + while (cursor < section.length) { + const chunk = section.slice(cursor, cursor + maxChars).trim(); + if (chunk.length > 0) { + excerpts.push({ + heading: currentHeading, + text: chunk, + }); + } + cursor += maxChars; + } + } + + return excerpts; +} + +export function buildArchiveSourceFingerprint(documents: ArchiveSourceDocument[]): string { + return documents + .map((document) => `${document.id}:${document.updatedAt}:${document.content.length}`) + .join("|"); +} + +function shouldPersistContextArchive(documents: ArchiveSourceDocument[]): boolean { + const totalChars = documents.reduce((sum, document) => sum + document.content.length, 0); + return totalChars >= ARCHIVE_SOURCE_CHAR_THRESHOLD || documents.length >= ARCHIVE_SOURCE_COUNT_THRESHOLD; +} + +function getLatestContextArchiveManifest( + artifacts: DeepResearchArtifact[], +): ResearchContextArchiveManifest | null { + const manifests = artifacts + .filter((artifact) => artifact.artifactType === "data_manifest") + .map((artifact) => artifact.content as unknown as Partial<ResearchContextArchiveManifest>) + .filter((artifact): artifact is ResearchContextArchiveManifest => + artifact.manifestKind === ARCHIVE_MANIFEST_KIND + && typeof artifact.archiveDir === "string" + && Array.isArray(artifact.records), + ); + + return manifests.length > 0 ? manifests[manifests.length - 1] : null; +} + +function buildArchiveSourceDocuments( + messages: DeepResearchMessage[], + artifacts: DeepResearchArtifact[], +): ArchiveSourceDocument[] { + const docs: ArchiveSourceDocument[] = []; + + const relevantArtifacts = artifacts + .filter((artifact) => !artifact.artifactType.startsWith("memory_") && artifact.artifactType !== "data_manifest") + .filter((artifact) => isArchivableArtifactType(artifact.artifactType)) + .slice(-MAX_ARCHIVE_SOURCE_FILES); + + for (const artifact of relevantArtifacts) { + const content = buildArchivableArtifactText(artifact).trim(); + if (content.length === 0) { + continue; + } + + docs.push({ + id: `artifact-${artifact.id}`, + title: artifact.title, + category: "artifact", + artifactId: artifact.id, + artifactType: artifact.artifactType, + nodeId: artifact.nodeId, + createdAt: artifact.createdAt, + updatedAt: artifact.createdAt, + importance: getArtifactArchiveImportance(artifact), + content: truncateContentForArchive(content), + summary: truncateSummary(extractArtifactSummary(artifact, content)), + keywords: dedupeStrings([ + ...extractKeywords(artifact.title), + ...extractKeywords(content), + ]).slice(0, 18), + }); + } + + const recentMessages = messages + .filter((message) => message.role === "user" || message.role === "main_brain") + .slice(-8); + + for (const message of recentMessages) { + const content = message.content.trim(); + if (content.length < 280) { + continue; + } + + docs.push({ + id: `message-${message.id}`, + title: `${message.role} message ${message.createdAt}`, + category: "message", + messageId: message.id, + createdAt: message.createdAt, + updatedAt: message.createdAt, + importance: message.role === "user" ? 0.92 : 0.7, + content: truncateContentForArchive(content), + summary: truncateSummary(content), + keywords: extractKeywords(content).slice(0, 18), + }); + } + + return docs.sort((left, right) => { + if (left.importance !== right.importance) { + return right.importance - left.importance; + } + return left.createdAt.localeCompare(right.createdAt); + }); +} + +function buildArchivableArtifactText(artifact: DeepResearchArtifact): string { + switch (artifact.artifactType) { + case "evidence_card": + return buildEvidenceCardArchiveText(artifact); + case "claim_map": + return buildClaimMapArchiveText(artifact); + case "checkpoint": + return buildCheckpointArchiveText(artifact); + case "review_assessment": + return buildReviewArchiveText(artifact); + default: + return extractArtifactTextCandidate(artifact); + } +} + +function buildEvidenceCardArchiveText(artifact: DeepResearchArtifact): string { + const query = typeof artifact.content.query === "string" ? artifact.content.query : artifact.title; + const coverage = typeof artifact.content.coverageSummary === "string" ? artifact.content.coverageSummary : ""; + const sources = Array.isArray(artifact.content.sources) ? artifact.content.sources as Array<Record<string, unknown>> : []; + const excerpts = Array.isArray(artifact.content.rawExcerpts) ? artifact.content.rawExcerpts as Array<Record<string, unknown>> : []; + + return [ + `# ${artifact.title}`, + "", + `## Query`, + query, + coverage ? `\n## Coverage Summary\n${coverage}` : "", + sources.length > 0 + ? `\n## Sources\n${sources.slice(0, 20).map((source) => { + const title = typeof source.title === "string" ? source.title : "Untitled source"; + const year = typeof source.year === "number" ? ` (${source.year})` : ""; + const venue = typeof source.venue === "string" && source.venue.trim().length > 0 ? ` - ${source.venue.trim()}` : ""; + const url = typeof source.url === "string" && source.url.trim().length > 0 ? ` | ${source.url.trim()}` : ""; + return `- ${title}${year}${venue}${url}`; + }).join("\n")}` + : "", + excerpts.length > 0 + ? `\n## Representative Excerpts\n${excerpts.slice(0, 8).map((excerpt, index) => { + const text = typeof excerpt.text === "string" ? excerpt.text : JSON.stringify(excerpt); + const section = typeof excerpt.section === "string" ? ` (${excerpt.section})` : ""; + return `### Excerpt ${index + 1}${section}\n${text}`; + }).join("\n\n")}` + : "", + ].filter(Boolean).join("\n"); +} + +function buildClaimMapArchiveText(artifact: DeepResearchArtifact): string { + const claimMap = artifact.content as unknown as ClaimMap; + return [ + `# ${artifact.title}`, + "", + `## Claims`, + (claimMap.claims ?? []).slice(0, 20).map((claim) => + `- [${claim.strength}] ${claim.text} (knowledge=${claim.knowledgeType}, category=${claim.category})` + ).join("\n"), + `\n## Contradictions`, + (claimMap.contradictions ?? []).slice(0, 10).map((item) => + `- ${item.description} | resolution=${item.possibleResolution}` + ).join("\n"), + `\n## Gaps`, + (claimMap.gaps ?? []).slice(0, 12).map((gap) => + `- ${gap.topic} [${gap.priority}] ${gap.description} | queries=${gap.suggestedQueries.join("; ")}` + ).join("\n"), + ].join("\n"); +} + +function buildCheckpointArchiveText(artifact: DeepResearchArtifact): string { + const checkpoint = artifact.content as unknown as CheckpointPackage; + return [ + `# ${checkpoint.title}`, + "", + `## Human Summary`, + checkpoint.humanSummary, + `\n## Machine Summary`, + checkpoint.machineSummary, + `\n## Current Findings`, + checkpoint.currentFindings, + checkpoint.openQuestions.length > 0 + ? `\n## Open Questions\n${checkpoint.openQuestions.map((item) => `- ${item}`).join("\n")}` + : "", + `\n## Recommended Next Action`, + checkpoint.recommendedNextAction, + ].filter(Boolean).join("\n"); +} + +function buildReviewArchiveText(artifact: DeepResearchArtifact): string { + const review = artifact.content as unknown as ReviewAssessment; + const openIssues = review.openIssues ?? []; + const literatureGaps = review.literatureGaps ?? []; + const suggestedExperiments = review.suggestedExperiments ?? []; + return [ + `# ${artifact.title}`, + "", + `## Verdict`, + `${review.combinedVerdict} (confidence=${review.combinedConfidence})`, + review.reviewerSummary ? `\n## Summary\n${review.reviewerSummary}` : "", + openIssues.length > 0 + ? `\n## Open Issues\n${openIssues.map((item) => `- ${item}`).join("\n")}` + : "", + literatureGaps.length > 0 + ? `\n## Literature Gaps\n${literatureGaps.map((item) => `- ${item}`).join("\n")}` + : "", + suggestedExperiments.length > 0 + ? `\n## Suggested Experiments\n${suggestedExperiments.map((item) => `- ${item}`).join("\n")}` + : "", + ].filter(Boolean).join("\n"); +} + +function extractArtifactTextCandidate(artifact: DeepResearchArtifact): string { + const candidates = [ + artifact.content.report, + artifact.content.summary, + artifact.content.machineSummary, + artifact.content.humanSummary, + artifact.content.currentFindings, + artifact.content.messageToUser, + artifact.content.text, + artifact.content.content, + ]; + + const preferred = candidates.find((candidate): candidate is string => + typeof candidate === "string" && candidate.trim().length > 0 + ); + if (preferred) { + return preferred.trim(); + } + + return JSON.stringify(artifact.content, null, 2); +} + +function extractArtifactSummary(artifact: DeepResearchArtifact, content: string): string { + if (typeof artifact.content.summary === "string" && artifact.content.summary.trim().length > 0) { + return artifact.content.summary.trim(); + } + if (typeof artifact.content.coverageSummary === "string" && artifact.content.coverageSummary.trim().length > 0) { + return artifact.content.coverageSummary.trim(); + } + return `${artifact.title}: ${content}`; +} + +function buildArchiveMarkdown( + source: ArchiveSourceDocument, + record: ResearchContextArchiveRecord, +): string { + return [ + `# ${source.title}`, + "", + `- category: ${source.category}`, + source.artifactType ? `- artifactType: ${source.artifactType}` : null, + source.artifactId ? `- artifactId: ${source.artifactId}` : null, + source.messageId ? `- messageId: ${source.messageId}` : null, + source.nodeId ? `- nodeId: ${source.nodeId}` : null, + `- createdAt: ${source.createdAt}`, + `- importance: ${source.importance.toFixed(2)}`, + "", + "## Summary", + source.summary, + "", + "## Retrieved Excerpts Index", + ...record.excerpts.map((excerpt, index) => `- ${index + 1}. ${excerpt.heading} (${excerpt.charCount} chars)`), + "", + "## Archived Content", + source.content, + ].filter((line): line is string => Boolean(line)).join("\n"); +} + +function scoreArchiveExcerpt( + record: ResearchContextArchiveRecord, + excerpt: ResearchContextArchiveExcerpt, + query: string, +): number { + const queryTokens = extractKeywords(query); + if (queryTokens.length === 0) { + return record.importance; + } + + const tokenSet = new Set([ + ...record.keywords, + ...excerpt.keywords, + ...extractKeywords(record.title), + ...extractKeywords(record.summary), + ]); + + let overlap = 0; + for (const token of queryTokens) { + if (tokenSet.has(token)) { + overlap += 1; + } + } + + return overlap * 2.6 + record.importance * 1.8 + computeRecencyBonus(record.updatedAt); +} + +function isArchivableArtifactType(type: DeepResearchArtifact["artifactType"]): boolean { + return new Set<DeepResearchArtifact["artifactType"]>([ + "research_brief", + "task_graph", + "evidence_card", + "literature_round_summary", + "structured_summary", + "reviewer_packet", + "review_assessment", + "provisional_conclusion", + "validation_report", + "final_report", + "checkpoint", + "claim_map", + ]).has(type); +} + +function getArtifactArchiveImportance(artifact: DeepResearchArtifact): number { + switch (artifact.artifactType) { + case "final_report": + return 0.95; + case "claim_map": + case "structured_summary": + case "provisional_conclusion": + case "validation_report": + return 0.9; + case "review_assessment": + case "checkpoint": + return 0.82; + case "evidence_card": + return 0.78; + default: + return 0.68; + } +} + +async function resolveWorkspace(workspaceId: string): Promise<{ folderPath: string } | null> { + const [workspace] = await db + .select({ folderPath: workspaces.folderPath }) + .from(workspaces) + .where(eq(workspaces.id, workspaceId)); + + return workspace ?? null; +} + +function truncateContentForArchive(content: string): string { + if (content.length <= MAX_ARCHIVE_SOURCE_CHARS) { + return content; + } + return `${content.slice(0, MAX_ARCHIVE_SOURCE_CHARS).trimEnd()}\n\n…truncated for archive safety`; +} + +function truncateSummary(content: string): string { + const compact = content.replace(/\s+/g, " ").trim(); + return compact.length <= 220 ? compact : `${compact.slice(0, 217).trimEnd()}...`; +} + +function truncatePromptBlock(block: string, maxChars: number): string { + if (block.length <= maxChars) { + return block; + } + return `${block.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + +function slugifyFileName(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff]+/gi, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "context"; +} + +function extractKeywords(value: string): string[] { + return dedupeStrings( + value + .toLowerCase() + .replace(/[^\p{L}\p{N}\s_-]/gu, " ") + .split(/\s+/) + .map((token) => token.trim()) + .filter((token) => token.length >= 3 && !STOP_WORDS.has(token)), + ); +} + +function dedupeStrings(values: string[]): string[] { + const seen = new Set<string>(); + const result: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + result.push(normalized); + } + return result; +} + +function computeRecencyBonus(timestamp: string): number { + const ageMs = Date.now() - new Date(timestamp).getTime(); + if (!Number.isFinite(ageMs) || ageMs < 0) { + return 0.3; + } + const ageDays = ageMs / (1000 * 60 * 60 * 24); + if (ageDays <= 1) return 1.1; + if (ageDays <= 7) return 0.8; + if (ageDays <= 30) return 0.35; + return 0.1; +} + +const STOP_WORDS = new Set([ + "about", + "after", + "again", + "also", + "been", + "between", + "from", + "have", + "into", + "just", + "more", + "most", + "that", + "their", + "them", + "then", + "there", + "these", + "they", + "this", + "those", + "using", + "with", + "without", + "what", + "when", + "where", + "which", + "while", + "would", + "research", + "study", + "session", + "node", + "task", +]); diff --git a/src/lib/deep-research/context-tag.ts b/src/lib/deep-research/context-tag.ts new file mode 100644 index 00000000..bac61e1f --- /dev/null +++ b/src/lib/deep-research/context-tag.ts @@ -0,0 +1,34 @@ +import type { ContextTag, DeepResearchNode, NodeCreationSpec } from "./types"; +import { VALID_CONTEXT_TAGS } from "./types"; + +export function resolveLegacyContextFromNodes(nodes: DeepResearchNode[], fallback: ContextTag): ContextTag { + const activeNode = [...nodes] + .filter((node) => + node.status !== "superseded" && + node.status !== "skipped" && + node.status !== "completed" && + node.status !== "failed" + ) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0]; + + return activeNode?.contextTag ?? fallback; +} + +export function validateContextTag(contextTag: string | undefined, fallback: ContextTag): ContextTag { + if (!contextTag) return fallback; + + const normalized = contextTag.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (VALID_CONTEXT_TAGS.includes(normalized as ContextTag)) return normalized as ContextTag; + if (normalized === "report") return "final_report"; + if (normalized === "plan") return "planning"; + if (normalized === "start") return "intake"; + return "planning"; +} + +export function resolveContextTagFromSpecs(specs: NodeCreationSpec[], fallback: ContextTag): ContextTag { + const explicitContextTag = specs.find( + (spec): spec is NodeCreationSpec & { contextTag: ContextTag } => Boolean(spec.contextTag), + )?.contextTag; + + return explicitContextTag ? validateContextTag(explicitContextTag, fallback) : fallback; +} diff --git a/src/lib/deep-research/data-acquisition.ts b/src/lib/deep-research/data-acquisition.ts deleted file mode 100644 index 0b0aa6f6..00000000 --- a/src/lib/deep-research/data-acquisition.ts +++ /dev/null @@ -1,267 +0,0 @@ -// ============================================================= -// Deep Research — Data Acquisition Module -// ============================================================= -// Pure utility functions for acquiring datasets from HuggingFace Hub, -// GitHub repositories, and arbitrary URLs. Generates commands and -// manifests — does NOT execute anything directly. - -// ------------------------------------------------------------------- -// Types -// ------------------------------------------------------------------- - -export type DataSource = "huggingface" | "github" | "url"; - -export interface DataAcquisitionRequest { - /** Source type. */ - source: DataSource; - /** Repository ID (e.g. "meta-llama/Llama-3-8B"), dataset ID, or URL. */ - identifier: string; - /** Optional subset/config name for HF datasets. */ - subset?: string; - /** Optional split (train/val/test). */ - split?: string; - /** Expected format of the data. */ - format?: string; - /** Local cache path to download to. */ - cachePath?: string; - /** Estimated size in GB. */ - estimatedSizeGb?: number; - /** Whether to use streaming mode (HF datasets). */ - streaming?: boolean; - /** HuggingFace token for gated repos. */ - hfToken?: string; -} - -export interface DataManifest { - /** Unique manifest ID. */ - id: string; - /** Source type. */ - source: DataSource; - /** Original identifier. */ - identifier: string; - /** Local path where data is stored. */ - localPath: string; - /** Data format. */ - format: string; - /** Size in bytes (populated after download). */ - sizeBytes?: number; - /** List of files in the download. */ - files: string[]; - /** When the download completed. */ - downloadedAt: string; - /** Current status. */ - status: "pending" | "downloading" | "ready" | "failed"; - /** Error message if failed. */ - error?: string; -} - -export interface DataAcquisitionStep { - request: DataAcquisitionRequest; - command: string; - estimatedDuration: string; - description: string; -} - -// ------------------------------------------------------------------- -// HuggingFace download commands -// ------------------------------------------------------------------- - -/** - * Generate a shell command to download from HuggingFace. - * Supports both `datasets` library (for datasets) and `huggingface-cli` (for models/repos). - */ -export function buildHuggingFaceDownloadCommand(request: DataAcquisitionRequest): string { - const cachePath = request.cachePath ?? `/mnt/data/hf/${sanitizePath(request.identifier)}`; - const tokenEnv = request.hfToken ? `HF_TOKEN=${request.hfToken} ` : ""; - - // Detect if this is a dataset (contains common dataset patterns) - const isDataset = request.identifier.includes("/") && - (request.format === "dataset" || - request.identifier.match(/^[a-z0-9_-]+\/[a-z0-9_.-]+$/i) !== null); - - if (request.streaming) { - // Streaming mode: generate a Python snippet that streams and saves - const subset = request.subset ? `, "${request.subset}"` : ""; - const split = request.split ?? "train"; - return `${tokenEnv}python3 -c " -from datasets import load_dataset -ds = load_dataset('${request.identifier}'${subset}, split='${split}', streaming=True) -import json -with open('${cachePath}/${split}.jsonl', 'w') as f: - for i, example in enumerate(ds): - f.write(json.dumps(example, ensure_ascii=False) + '\\n') - if i % 10000 == 0: - print(f'Processed {i} examples...') -print('Done.') -"`; - } - - if (isDataset || request.format === "dataset") { - // Use datasets library - const subset = request.subset ? `, "${request.subset}"` : ""; - const split = request.split ? `, split="${request.split}"` : ""; - return `${tokenEnv}python3 -c " -from datasets import load_dataset -ds = load_dataset('${request.identifier}'${subset}${split}) -ds.save_to_disk('${cachePath}') -print(f'Saved to ${cachePath}') -print(f'Features: {ds.features if hasattr(ds, \"features\") else \"N/A\"}') -"`; - } - - // Default: use huggingface-cli for model/repo downloads - const tokenFlag = request.hfToken ? ` --token ${request.hfToken}` : ""; - return `huggingface-cli download ${request.identifier} --local-dir ${cachePath}${tokenFlag}`; -} - -// ------------------------------------------------------------------- -// GitHub download commands -// ------------------------------------------------------------------- - -/** - * Generate a shell command to download from GitHub. - * Handles: release assets (curl), repositories (git clone), and raw files. - */ -export function buildGitHubDownloadCommand(request: DataAcquisitionRequest): string { - const cachePath = request.cachePath ?? `/mnt/data/github/${sanitizePath(request.identifier)}`; - const identifier = request.identifier; - - // Release asset URL pattern: github.com/owner/repo/releases/download/tag/file - if (identifier.includes("/releases/download/")) { - const filename = identifier.split("/").pop() ?? "download"; - return `mkdir -p ${cachePath} && curl -L -o ${cachePath}/${filename} "${identifier}"`; - } - - // Raw file URL - if (identifier.includes("/raw/") || identifier.includes("raw.githubusercontent.com")) { - const filename = identifier.split("/").pop() ?? "download"; - return `mkdir -p ${cachePath} && curl -L -o ${cachePath}/${filename} "${identifier}"`; - } - - // Archive URL (.tar.gz, .zip) - if (identifier.match(/\.(tar\.gz|tgz|zip)$/)) { - const ext = identifier.match(/\.(tar\.gz|tgz|zip)$/)?.[0] ?? ".tar.gz"; - if (ext === ".zip") { - return `mkdir -p ${cachePath} && curl -L -o ${cachePath}/archive.zip "${identifier}" && cd ${cachePath} && unzip archive.zip`; - } - return `mkdir -p ${cachePath} && curl -L "${identifier}" | tar xzf - -C ${cachePath}`; - } - - // Default: git clone (shallow) - const url = identifier.startsWith("http") ? identifier : `https://github.com/${identifier}`; - return `git clone --depth 1 ${url} ${cachePath}`; -} - -// ------------------------------------------------------------------- -// Generic URL download -// ------------------------------------------------------------------- - -/** - * Generate a download command for an arbitrary URL. - */ -export function buildUrlDownloadCommand(request: DataAcquisitionRequest): string { - const cachePath = request.cachePath ?? `/mnt/data/downloads/${sanitizePath(request.identifier)}`; - const filename = request.identifier.split("/").pop() ?? "download"; - return `mkdir -p ${cachePath} && curl -L -o ${cachePath}/${filename} "${request.identifier}"`; -} - -// ------------------------------------------------------------------- -// Acquisition plan builder -// ------------------------------------------------------------------- - -/** - * Build a complete data acquisition plan from a list of requests. - * Returns ordered steps with commands and time estimates. - */ -export function buildDataAcquisitionPlan(requests: DataAcquisitionRequest[]): DataAcquisitionStep[] { - return requests.map(request => { - let command: string; - let description: string; - - switch (request.source) { - case "huggingface": - command = buildHuggingFaceDownloadCommand(request); - description = `Download from HuggingFace: ${request.identifier}`; - break; - case "github": - command = buildGitHubDownloadCommand(request); - description = `Download from GitHub: ${request.identifier}`; - break; - case "url": - command = buildUrlDownloadCommand(request); - description = `Download from URL: ${request.identifier}`; - break; - default: - command = `echo "Unknown source: ${request.source}"`; - description = `Unknown source: ${request.identifier}`; - } - - const estimatedDuration = estimateDownloadDuration(request.estimatedSizeGb ?? 1); - - return { request, command, estimatedDuration, description }; - }); -} - -// ------------------------------------------------------------------- -// Manifest management -// ------------------------------------------------------------------- - -/** - * Create a DataManifest for a completed or pending download. - */ -export function createDataManifest( - request: DataAcquisitionRequest, - localPath: string, - files: string[], - status: DataManifest["status"] = "pending", -): DataManifest { - return { - id: `dm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, - source: request.source, - identifier: request.identifier, - localPath, - format: request.format ?? "unknown", - files, - downloadedAt: new Date().toISOString(), - status, - }; -} - -/** - * Convert a DataManifest to artifact content for storage. - */ -export function dataManifestToArtifact(manifest: DataManifest): Record<string, unknown> { - return { - manifestId: manifest.id, - source: manifest.source, - identifier: manifest.identifier, - localPath: manifest.localPath, - format: manifest.format, - sizeBytes: manifest.sizeBytes, - fileCount: manifest.files.length, - files: manifest.files, - downloadedAt: manifest.downloadedAt, - status: manifest.status, - error: manifest.error, - }; -} - -// ------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------- - -function sanitizePath(identifier: string): string { - return identifier - .replace(/https?:\/\//g, "") - .replace(/[^a-zA-Z0-9_\-./]/g, "_") - .replace(/\/+/g, "/") - .replace(/^\/|\/$/g, ""); -} - -function estimateDownloadDuration(sizeGb: number): string { - // Assume ~100MB/s download speed - const seconds = (sizeGb * 1024) / 100; - if (seconds < 60) return `~${Math.ceil(seconds)}s`; - if (seconds < 3600) return `~${Math.ceil(seconds / 60)}min`; - return `~${(seconds / 3600).toFixed(1)}h`; -} diff --git a/src/lib/deep-research/dispatch-policy.test.ts b/src/lib/deep-research/dispatch-policy.test.ts new file mode 100644 index 00000000..ec3dfdbb --- /dev/null +++ b/src/lib/deep-research/dispatch-policy.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + resolveNodeDependencies, + rewriteNodeSpecsForWorkflowPolicy, +} from "./dispatch-policy"; +import type { NodeCreationSpec } from "./types"; + +function createSpec(overrides: Partial<NodeCreationSpec>): NodeCreationSpec { + return { + nodeType: overrides.nodeType ?? "validation_plan", + label: overrides.label ?? "时间序列Transformer综述架构设计:三大技术路线的结构化框架构建", + assignedRole: overrides.assignedRole ?? "experiment_architecture_designer", + input: overrides.input, + dependsOn: overrides.dependsOn, + parentId: overrides.parentId, + branchKey: overrides.branchKey, + contextTag: overrides.contextTag ?? "planning", + }; +} + +describe("dispatch-policy", () => { + it("rewrites conceptual validation-plan specs in analysis-only mode", () => { + const { rewrittenSpecs, rewrites } = rewriteNodeSpecsForWorkflowPolicy( + [createSpec({})], + { + mode: "analysis_only", + requiresInitialPlanConfirmation: false, + blockedNodeTypes: new Set(["validation_plan"]), + reasoning: [], + promptBlock: "", + }, + ); + + expect(rewrites).toEqual([ + "时间序列Transformer综述架构设计:三大技术路线的结构化框架构建 (validation_plan -> summarize)", + ]); + expect(rewrittenSpecs[0]).toMatchObject({ + nodeType: "summarize", + assignedRole: "results_and_evidence_analyst", + }); + }); + + it("does not rewrite genuine experiment planning requests", () => { + const { rewrittenSpecs, rewrites } = rewriteNodeSpecsForWorkflowPolicy( + [createSpec({ + label: "设计消融实验与验证计划", + input: { objective: "benchmark and ablation evaluation" }, + })], + { + mode: "analysis_only", + requiresInitialPlanConfirmation: false, + blockedNodeTypes: new Set(["validation_plan"]), + reasoning: [], + promptBlock: "", + }, + ); + + expect(rewrites).toHaveLength(0); + expect(rewrittenSpecs[0]?.nodeType).toBe("validation_plan"); + expect(rewrittenSpecs[0]?.assignedRole).toBe("experiment_architecture_designer"); + }); + + it("resolves short node ids in dependsOn references", () => { + const resolved = resolveNodeDependencies( + ["yQzevwDS", "Generate summary"], + new Set(["yQzevwDScO3hWTd7QkBWe"]), + new Map([["Generate summary", "full-summary-node-id"]]), + new Map(), + ); + + expect(resolved).toEqual([ + "yQzevwDScO3hWTd7QkBWe", + "full-summary-node-id", + ]); + }); +}); diff --git a/src/lib/deep-research/dispatch-policy.ts b/src/lib/deep-research/dispatch-policy.ts new file mode 100644 index 00000000..798f6ac3 --- /dev/null +++ b/src/lib/deep-research/dispatch-policy.ts @@ -0,0 +1,202 @@ +import * as store from "./event-store"; +import { normalizeNodeCreationSpecs } from "./node-spec-normalizer"; +import { filterNodeSpecsForWorkflowPolicy, type WorkflowPolicy } from "./workflow-policy"; +import type { ContextTag, DeepResearchNode, NodeCreationSpec } from "./types"; + +export function countNodesByType(specs: NodeCreationSpec[]): Record<string, number> { + const counts: Record<string, number> = {}; + for (const spec of specs) { + counts[spec.nodeType] = (counts[spec.nodeType] ?? 0) + 1; + } + return counts; +} + +export async function normalizeAndLimitNodeSpecs( + sessionId: string, + rawSpecs: unknown[], + defaultContextTag: ContextTag, + workflowPolicy: WorkflowPolicy, + source: string, +): Promise<NodeCreationSpec[]> { + const { validSpecs, droppedSpecs } = normalizeNodeCreationSpecs(rawSpecs, defaultContextTag); + if (droppedSpecs.length > 0) { + await store.addMessage( + sessionId, + "system", + `${droppedSpecs.length} malformed task(s) were ignored before ${source}.`, + ); + } + + const { rewrittenSpecs, rewrites } = rewriteNodeSpecsForWorkflowPolicy(validSpecs, workflowPolicy); + if (rewrites.length > 0) { + await store.addMessage( + sessionId, + "system", + `Rewrote ${rewrites.length} conceptual analysis task(s) during ${source} to fit the current workflow policy: ${rewrites.join(", ")}.`, + ); + } + + const { allowedSpecs, blockedSpecs } = filterNodeSpecsForWorkflowPolicy(rewrittenSpecs, workflowPolicy); + if (blockedSpecs.length > 0) { + await store.addMessage( + sessionId, + "system", + `Blocked ${blockedSpecs.length} task(s) during ${source} because they do not fit the current workflow policy: ${blockedSpecs.map((spec) => `${spec.label} (${spec.nodeType})`).join(", ")}.`, + ); + } + + return enforceSingleWorkerDispatch(sessionId, allowedSpecs, source); +} + +export function rewriteNodeSpecsForWorkflowPolicy( + specs: NodeCreationSpec[], + workflowPolicy: WorkflowPolicy, +): { + rewrittenSpecs: NodeCreationSpec[]; + rewrites: string[]; +} { + if (workflowPolicy.mode !== "analysis_only") { + return { rewrittenSpecs: specs, rewrites: [] }; + } + + const rewrites: string[] = []; + const rewrittenSpecs = specs.map((spec) => { + if (spec.nodeType !== "validation_plan" || !looksLikeConceptualFrameworkTask(spec)) { + return spec; + } + + rewrites.push(`${spec.label} (validation_plan -> summarize)`); + const rewrittenSpec: NodeCreationSpec = { + ...spec, + nodeType: "summarize", + assignedRole: "results_and_evidence_analyst", + }; + return rewrittenSpec; + }); + + return { + rewrittenSpecs, + rewrites, + }; +} + +export async function selectNextReadyNodeForWorkflow( + sessionId: string, + workflowPolicy: WorkflowPolicy, +): Promise<DeepResearchNode | undefined> { + const readyNodes = (await store.getReadyNodes(sessionId)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + + const blockedReadyNodes = readyNodes.filter((node) => workflowPolicy.blockedNodeTypes.has(node.nodeType)); + if (blockedReadyNodes.length > 0) { + const completedAt = new Date().toISOString(); + for (const node of blockedReadyNodes) { + await store.updateNode(node.id, { + status: "skipped", + completedAt, + error: `Skipped by workflow policy (${workflowPolicy.mode}).`, + }); + } + + await store.addMessage( + sessionId, + "system", + `Skipped ${blockedReadyNodes.length} ready node(s) because the session is currently in ${workflowPolicy.mode} mode: ${blockedReadyNodes.map((node) => `${node.label} (${node.nodeType})`).join(", ")}.`, + ); + } + + return readyNodes.find((node) => !workflowPolicy.blockedNodeTypes.has(node.nodeType)); +} + +export function resolveNodeDependencies( + dependsOn: string[], + existingNodeIds: Set<string>, + existingNodeIdsByLabel: Map<string, string>, + createdNodeIdsByLabel: Map<string, string>, +): string[] { + const resolved = new Set<string>(); + const createdNodeIds = [...createdNodeIdsByLabel.values()]; + const knownNodeIds = [ + ...existingNodeIds, + ...createdNodeIds, + ]; + + for (const dependency of dependsOn) { + const normalizedDependency = dependency.trim(); + if (!normalizedDependency) { + continue; + } + + if (existingNodeIds.has(normalizedDependency)) { + resolved.add(normalizedDependency); + continue; + } + + if (createdNodeIds.includes(normalizedDependency)) { + resolved.add(normalizedDependency); + continue; + } + + const prefixMatches = knownNodeIds.filter((nodeId) => nodeId.startsWith(normalizedDependency)); + if (prefixMatches.length === 1) { + resolved.add(prefixMatches[0]); + continue; + } + + const existingMatch = existingNodeIdsByLabel.get(normalizedDependency); + if (existingMatch) { + resolved.add(existingMatch); + continue; + } + + const createdMatch = createdNodeIdsByLabel.get(normalizedDependency); + if (createdMatch) { + resolved.add(createdMatch); + continue; + } + } + + return [...resolved]; +} + +async function enforceSingleWorkerDispatch( + sessionId: string, + specs: NodeCreationSpec[], + source: string, +): Promise<NodeCreationSpec[]> { + if (specs.length <= 1) { + return specs; + } + + await store.addMessage( + sessionId, + "system", + `${specs.length - 1} extra task(s) from ${source} were dropped. Deep Research now dispatches at most one worker task at a time.`, + ); + + return specs.slice(0, 1); +} + +function looksLikeConceptualFrameworkTask(spec: NodeCreationSpec): boolean { + const input = spec.input && typeof spec.input === "object" ? spec.input : {}; + const text = [ + spec.label, + typeof input.objective === "string" ? input.objective : "", + typeof input.description === "string" ? input.description : "", + typeof input.query === "string" ? input.query : "", + Array.isArray(input.deliverables) ? input.deliverables.join(" ") : "", + Array.isArray(input.completionCriteria) ? input.completionCriteria.join(" ") : "", + ] + .join(" ") + .toLowerCase(); + + const conceptualSignals = [ + /综述|调研|梳理|框架|架构|谱系|路线|机制|比较|分类|taxonomy|survey|review|framework|landscape|architecture|comparison|mechanism/, + ].some((pattern) => pattern.test(text)); + + const experimentalSignals = [ + /实验|评测|验证|跑实验|benchmark|evaluate|evaluation|ablation|training|train|reproduce|replicate|execution/, + ].some((pattern) => pattern.test(text)); + + return conceptualSignals && !experimentalSignals; +} diff --git a/src/lib/deep-research/event-store.ts b/src/lib/deep-research/event-store.ts index 932074ee..8e59f031 100644 --- a/src/lib/deep-research/event-store.ts +++ b/src/lib/deep-research/event-store.ts @@ -11,6 +11,7 @@ import { } from "@/lib/db/schema"; import { eq, and, desc, gt } from "drizzle-orm"; import { nanoid } from "nanoid"; +import { buildDeepResearchConfigForResolvedModel } from "./model-overrides"; import { DEFAULT_CONFIG, createEmptyUsage, @@ -189,18 +190,22 @@ export async function createSession( const now = new Date().toISOString(); const configuredModel = await getConfiguredModelSelection(); - const fullConfig: DeepResearchConfig = { + const fullConfigBase: DeepResearchConfig = { ...DEFAULT_CONFIG, ...config, resolvedModel: config?.resolvedModel ?? { provider: configuredModel.providerId, modelId: configuredModel.modelId, }, - modelOverrides: undefined, + modelOverrides: config?.modelOverrides, budget: { ...DEFAULT_CONFIG.budget, ...config?.budget }, literature: { ...DEFAULT_CONFIG.literature, ...config?.literature }, execution: { ...DEFAULT_CONFIG.execution, ...config?.execution }, }; + const fullConfig = buildDeepResearchConfigForResolvedModel( + fullConfigBase, + fullConfigBase.resolvedModel!, + ); const usage = createEmptyUsage(); await db.insert(deepResearchSessions).values({ diff --git a/src/lib/deep-research/exec-config.ts b/src/lib/deep-research/exec-config.ts deleted file mode 100644 index 11d20e7d..00000000 --- a/src/lib/deep-research/exec-config.ts +++ /dev/null @@ -1,112 +0,0 @@ -// ============================================================= -// Execution Pipeline — Configuration -// ============================================================= -// Structured configuration for the execution pipeline. -// Merges user overrides with sensible defaults. - -import type { - ExecutionPipelineConfig, - ExperimentResources, - EnvironmentSetup, -} from "./types"; -import { DEFAULT_EXECUTION_PIPELINE_CONFIG } from "./types"; - -export { DEFAULT_EXECUTION_PIPELINE_CONFIG } from "./types"; - -export type ExecutionPipelineConfigOverrides = Partial<ExecutionPipelineConfig>; - -/** - * Merge user overrides with defaults. - */ -export function resolveConfig( - overrides?: ExecutionPipelineConfigOverrides, -): ExecutionPipelineConfig { - const defaults = DEFAULT_EXECUTION_PIPELINE_CONFIG; - if (!overrides) { - return { ...defaults }; - } - - return { - dataCacheDir: overrides.dataCacheDir ?? defaults.dataCacheDir, - experimentOutputRoot: overrides.experimentOutputRoot ?? defaults.experimentOutputRoot, - preprocessingOutputRoot: overrides.preprocessingOutputRoot ?? defaults.preprocessingOutputRoot, - defaultLauncherType: overrides.defaultLauncherType ?? defaults.defaultLauncherType, - defaultResources: { - ...defaults.defaultResources, - ...(overrides.defaultResources ?? {}), - }, - defaultMounts: overrides.defaultMounts ?? defaults.defaultMounts, - chargedGroup: overrides.chargedGroup ?? defaults.chargedGroup, - defaultEnvironment: { - ...defaults.defaultEnvironment, - ...(overrides.defaultEnvironment ?? {}), - }, - defaultRetryPolicy: { - ...defaults.defaultRetryPolicy, - ...(overrides.defaultRetryPolicy ?? {}), - }, - skipExistingData: overrides.skipExistingData ?? defaults.skipExistingData, - skipExistingPreprocessing: overrides.skipExistingPreprocessing ?? defaults.skipExistingPreprocessing, - }; -} - -/** - * Merge per-experiment resource overrides with defaults. - */ -export function resolveResources( - defaults: ExperimentResources, - overrides?: Partial<ExperimentResources>, -): ExperimentResources { - if (!overrides) return { ...defaults }; - return { - gpu: overrides.gpu ?? defaults.gpu, - gpuType: overrides.gpuType ?? defaults.gpuType, - cpu: overrides.cpu ?? defaults.cpu, - memoryMb: overrides.memoryMb ?? defaults.memoryMb, - diskGb: overrides.diskGb ?? defaults.diskGb, - walltime: overrides.walltime ?? defaults.walltime, - privateMachine: overrides.privateMachine ?? defaults.privateMachine, - maxWaitDuration: overrides.maxWaitDuration ?? defaults.maxWaitDuration, - }; -} - -/** - * Resolve environment setup with defaults. - */ -export function resolveEnvironment( - defaults: Partial<EnvironmentSetup>, - overrides?: Partial<EnvironmentSetup>, -): EnvironmentSetup { - return { - modules: overrides?.modules ?? (defaults.modules as string[]) ?? [], - envVars: { ...(defaults.envVars ?? {}), ...(overrides?.envVars ?? {}) }, - condaEnv: overrides?.condaEnv ?? defaults.condaEnv, - venvPath: overrides?.venvPath ?? defaults.venvPath, - setupCommands: [ - ...((defaults.setupCommands as string[]) ?? []), - ...((overrides?.setupCommands as string[]) ?? []), - ], - workingDir: overrides?.workingDir ?? (defaults.workingDir as string) ?? "/tmp", - }; -} - -/** - * Generate cache path for a dataset source. - */ -export function datasetCachePath(config: ExecutionPipelineConfig, sourceId: string): string { - return `${config.dataCacheDir}/${sourceId}`; -} - -/** - * Generate output path for an experiment. - */ -export function experimentOutputPath(config: ExecutionPipelineConfig, experimentId: string): string { - return `${config.experimentOutputRoot}/${experimentId}`; -} - -/** - * Generate preprocessing output path. - */ -export function preprocessingOutputPath(config: ExecutionPipelineConfig, experimentId: string): string { - return `${config.preprocessingOutputRoot}/${experimentId}`; -} diff --git a/src/lib/deep-research/exec-dataset-manager.ts b/src/lib/deep-research/exec-dataset-manager.ts deleted file mode 100644 index 826efe8b..00000000 --- a/src/lib/deep-research/exec-dataset-manager.ts +++ /dev/null @@ -1,285 +0,0 @@ -// ============================================================= -// Execution Pipeline — Dataset Acquisition Manager -// ============================================================= -// Handles downloading datasets from HuggingFace, GitHub, URLs. -// Supports caching, skip-if-exists, manifests, and checksums. - -import type { - DataSourceSpec, - DatasetAcquisitionResult, - ExecutionPipelineConfig, - ExperimentSpec, -} from "./types"; -import { - buildHuggingFaceDownloadCommand, - buildGitHubDownloadCommand, - buildUrlDownloadCommand, -} from "./data-acquisition"; -import type { DataAcquisitionRequest } from "./data-acquisition"; - -// ------------------------------------------------------------------- -// Download command builder -// ------------------------------------------------------------------- - -/** - * Convert a DataSourceSpec to the command that would download it. - */ -export function buildDownloadCommand(source: DataSourceSpec): string { - switch (source.source) { - case "huggingface": { - const req: DataAcquisitionRequest = { - source: "huggingface", - identifier: source.identifier, - subset: source.subset, - split: source.split, - format: source.format, - cachePath: source.cachePath, - streaming: false, - }; - return buildHuggingFaceDownloadCommand(req); - } - case "github": { - const req: DataAcquisitionRequest = { - source: "github", - identifier: source.identifier, - cachePath: source.cachePath, - }; - return buildGitHubDownloadCommand(req); - } - case "url": { - const req: DataAcquisitionRequest = { - source: "url", - identifier: source.identifier, - cachePath: source.cachePath, - }; - return buildUrlDownloadCommand(req); - } - case "local": - return `# Local source: ${source.identifier} → no download needed`; - default: - return `echo "Unknown source type: ${source.source}"`; - } -} - -// ------------------------------------------------------------------- -// File existence check (pure utility) -// ------------------------------------------------------------------- - -/** - * Check if a cache path appears to have data. - * In production this checks the filesystem; tests can override. - */ -export type FileExistenceChecker = (path: string) => boolean; - -let fileExistsChecker: FileExistenceChecker = defaultFileExists; - -export function setFileExistenceChecker(checker: FileExistenceChecker): void { - fileExistsChecker = checker; -} - -export function resetFileExistenceChecker(): void { - fileExistsChecker = defaultFileExists; -} - -function defaultFileExists(path: string): boolean { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const fs = require("fs"); - const stat = fs.statSync(path); - return stat.isDirectory() ? fs.readdirSync(path).length > 0 : stat.size > 0; - } catch { - return false; - } -} - -// ------------------------------------------------------------------- -// Dataset acquisition plan -// ------------------------------------------------------------------- - -export interface DatasetAcquisitionPlan { - sources: Array<{ - source: DataSourceSpec; - command: string; - willSkip: boolean; - skipReason?: string; - estimatedDuration: string; - }>; - totalSources: number; - sourcesToDownload: number; - sourcesToSkip: number; -} - -/** - * Build an acquisition plan for all data sources in an experiment. - * Determines which sources need downloading and which can be skipped. - */ -export function buildDatasetAcquisitionPlan( - spec: ExperimentSpec, - config: ExecutionPipelineConfig, -): DatasetAcquisitionPlan { - const sources = spec.dataSources.map(source => { - const command = buildDownloadCommand(source); - let willSkip = false; - let skipReason: string | undefined; - - // Check if data already exists - if (config.skipExistingData && source.cachePath) { - if (fileExistsChecker(source.cachePath)) { - willSkip = true; - skipReason = `Data already exists at ${source.cachePath}`; - } - } - - // Local sources are always "skip" - if (source.source === "local") { - willSkip = true; - skipReason = "Local source — no download needed"; - } - - // Estimate duration based on size - const gbPerMinute = 0.5; // Conservative estimate - const minutes = Math.ceil((source.estimatedSizeGb || 1) / gbPerMinute); - const estimatedDuration = minutes < 60 ? `~${minutes} min` : `~${(minutes / 60).toFixed(1)} hr`; - - return { source, command, willSkip, skipReason, estimatedDuration }; - }); - - return { - sources, - totalSources: sources.length, - sourcesToDownload: sources.filter(s => !s.willSkip).length, - sourcesToSkip: sources.filter(s => s.willSkip).length, - }; -} - -// ------------------------------------------------------------------- -// Execute acquisition (production uses child_process, tests use mock) -// ------------------------------------------------------------------- - -export type CommandExecutor = (command: string, opts?: { timeout?: number }) => Promise<{ stdout: string; exitCode: number }>; - -let commandExecutor: CommandExecutor = defaultCommandExecutor; - -export function setCommandExecutor(executor: CommandExecutor): void { - commandExecutor = executor; -} - -export function resetCommandExecutor(): void { - commandExecutor = defaultCommandExecutor; -} - -async function defaultCommandExecutor( - command: string, - opts?: { timeout?: number }, -): Promise<{ stdout: string; exitCode: number }> { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { exec } = require("child_process"); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { promisify } = require("util"); - const execAsync = promisify(exec); - - try { - const { stdout } = await execAsync(command, { - timeout: opts?.timeout ?? 600_000, - maxBuffer: 10 * 1024 * 1024, - }); - return { stdout: stdout ?? "", exitCode: 0 }; - } catch (error: unknown) { - const err = error as { code?: number; stderr?: string; message?: string }; - return { - stdout: err.stderr ?? err.message ?? "Unknown error", - exitCode: err.code ?? 1, - }; - } -} - -/** - * Execute the full dataset acquisition for an experiment. - * Downloads each source that isn't cached. - */ -export async function executeDatasetAcquisition( - spec: ExperimentSpec, - config: ExecutionPipelineConfig, -): Promise<DatasetAcquisitionResult[]> { - const plan = buildDatasetAcquisitionPlan(spec, config); - const results: DatasetAcquisitionResult[] = []; - - for (const entry of plan.sources) { - if (entry.willSkip) { - results.push({ - sourceId: entry.source.id, - source: entry.source, - status: "skipped", - localPath: entry.source.cachePath, - skippedReason: entry.skipReason, - command: entry.command, - }); - continue; - } - - // Execute download - try { - const { stdout, exitCode } = await commandExecutor(entry.command, { - timeout: 3600_000, // 1 hour max per download - }); - - if (exitCode !== 0) { - results.push({ - sourceId: entry.source.id, - source: entry.source, - status: "failed", - localPath: entry.source.cachePath, - error: `Download failed (exit ${exitCode}): ${stdout.slice(0, 500)}`, - command: entry.command, - }); - continue; - } - - results.push({ - sourceId: entry.source.id, - source: entry.source, - status: "ready", - localPath: entry.source.cachePath, - downloadedAt: new Date().toISOString(), - command: entry.command, - }); - } catch (error) { - results.push({ - sourceId: entry.source.id, - source: entry.source, - status: "failed", - localPath: entry.source.cachePath, - error: error instanceof Error ? error.message : "Download failed", - command: entry.command, - }); - } - } - - return results; -} - -/** - * Create a dataset manifest from acquisition results. - */ -export function createDatasetManifest( - results: DatasetAcquisitionResult[], -): Record<string, unknown> { - return { - totalSources: results.length, - ready: results.filter(r => r.status === "ready").length, - skipped: results.filter(r => r.status === "skipped").length, - failed: results.filter(r => r.status === "failed").length, - sources: results.map(r => ({ - id: r.sourceId, - identifier: r.source.identifier, - source: r.source.source, - status: r.status, - localPath: r.localPath, - sizeBytes: r.sizeBytes, - checksum: r.checksum, - downloadedAt: r.downloadedAt, - error: r.error, - })), - createdAt: new Date().toISOString(), - }; -} diff --git a/src/lib/deep-research/exec-job-submitter.ts b/src/lib/deep-research/exec-job-submitter.ts deleted file mode 100644 index 30b0b89c..00000000 --- a/src/lib/deep-research/exec-job-submitter.ts +++ /dev/null @@ -1,598 +0,0 @@ -// ============================================================= -// Execution Pipeline — Job Submission Layer -// ============================================================= -// Provides a SubmissionAdapter interface with real (rjob/slurm) and -// mock implementations. Separates spec generation from actual submission. - -import type { - ExperimentSpec, - MountSpec, - EnvironmentSetup, - JobSubmissionResult, - JobStatusResult, - JobLogResult, - JobOutputResult, - JobStatus, - SubmissionMode, - LauncherType, - RJobManifest, - RLaunchManifest, - SlurmManifest, - ExecutionManifest, -} from "./types"; -import { rjobToCommand, rlaunchToCommand } from "./execution-adapters"; -import { slurmToScript } from "./slurm-launcher"; - -// ------------------------------------------------------------------- -// Adapter interface -// ------------------------------------------------------------------- - -export interface SubmissionAdapter { - readonly name: string; - readonly launcherType: LauncherType; - - /** Render the job spec as a human-readable string. */ - renderSpec(spec: ExperimentSpec): string; - - /** Submit a job. In mock mode, returns a fake result. */ - submit(spec: ExperimentSpec, mode: SubmissionMode): Promise<JobSubmissionResult>; - - /** Query job status. */ - queryStatus(jobId: string): Promise<JobStatusResult>; - - /** Cancel a running job. */ - cancel(jobId: string): Promise<{ success: boolean; message: string }>; - - /** Fetch logs from a completed/running job (optional). */ - fetchLogs?(jobId: string): Promise<JobLogResult>; - - /** Fetch output files and metrics from a job (optional). */ - fetchOutputs?(outputDir: string): Promise<JobOutputResult>; -} - -// ------------------------------------------------------------------- -// Spec rendering helpers (shared) -// ------------------------------------------------------------------- - -/** - * Build an rjob manifest from an ExperimentSpec. - */ -export function specToRJobManifest(spec: ExperimentSpec): RJobManifest { - const envSetup = buildSetupScript(spec.environment); - const mainCommands = spec.commands - .filter(c => c.stage === "train" || c.stage === "eval") - .map(c => [c.command, ...c.args].join(" ")); - const fullCommand = [envSetup, ...mainCommands].filter(Boolean).join(" && "); - - return { - launcherType: "rjob", - jobName: spec.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64), - gpu: spec.resources.gpu, - memoryMb: spec.resources.memoryMb, - cpu: spec.resources.cpu, - chargedGroup: "", // Filled by adapter from config - privateMachine: spec.resources.privateMachine, - mounts: spec.mounts, - image: spec.environment.condaEnv - ? `registry.example.com/research:conda-${spec.environment.condaEnv}` - : "registry.example.com/research:latest", - command: "bash", - commandArgs: ["-exc", fullCommand], - env: spec.environment.envVars, - purpose: spec.description, - }; -} - -/** - * Build an rlaunch manifest from an ExperimentSpec. - */ -export function specToRLaunchManifest(spec: ExperimentSpec): RLaunchManifest { - const envSetup = buildSetupScript(spec.environment); - const mainCommands = spec.commands - .filter(c => c.stage === "train" || c.stage === "eval") - .map(c => [c.command, ...c.args].join(" ")); - const fullCommand = [envSetup, ...mainCommands].filter(Boolean).join(" && "); - - return { - launcherType: "rlaunch", - gpu: spec.resources.gpu, - memoryMb: spec.resources.memoryMb, - cpu: spec.resources.cpu, - chargedGroup: "", - privateMachine: spec.resources.privateMachine, - mounts: spec.mounts, - maxWaitDuration: spec.resources.maxWaitDuration ?? "2h", - command: fullCommand, - purpose: spec.description, - }; -} - -/** - * Build a Slurm manifest from an ExperimentSpec. - */ -export function specToSlurmManifest(spec: ExperimentSpec): SlurmManifest { - const envSetup = buildSetupScript(spec.environment); - const mainCommands = spec.commands - .filter(c => c.stage === "train" || c.stage === "eval") - .map(c => [c.command, ...c.args].join(" ")); - const fullCommand = [envSetup, ...mainCommands].filter(Boolean).join(" && "); - - return { - launcherType: "slurm", - partition: "gpu", - account: "", - nodes: 1, - gpusPerNode: spec.resources.gpu, - time: spec.resources.walltime, - modules: spec.environment.modules, - command: fullCommand, - jobName: spec.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64), - outputPath: `${spec.outputs.logDir}/%j.out`, - errorPath: `${spec.outputs.logDir}/%j.err`, - }; -} - -function buildSetupScript(env: EnvironmentSetup): string { - const parts: string[] = []; - if (env.modules.length > 0) { - parts.push(`module load ${env.modules.join(" ")}`); - } - if (env.condaEnv) { - parts.push(`conda activate ${env.condaEnv}`); - } - if (env.venvPath) { - parts.push(`source ${env.venvPath}/bin/activate`); - } - for (const cmd of env.setupCommands) { - parts.push(cmd); - } - if (env.workingDir) { - parts.push(`cd ${env.workingDir}`); - } - return parts.join(" && "); -} - -/** - * Render any manifest as human-readable text. - */ -export function renderManifest(manifest: ExecutionManifest): string { - switch (manifest.launcherType) { - case "rjob": - return rjobToCommand(manifest as RJobManifest); - case "rlaunch": - return rlaunchToCommand(manifest as RLaunchManifest); - case "slurm": - return slurmToScript(manifest as SlurmManifest); - default: - return JSON.stringify(manifest, null, 2); - } -} - -/** - * Render an ExperimentSpec as a human-readable job spec string. - */ -export function renderJobSpec(spec: ExperimentSpec): string { - const sections: string[] = []; - - sections.push(`# Experiment: ${spec.name}`); - sections.push(`# ID: ${spec.experimentId}`); - sections.push(`# Scale: ${spec.scale}`); - sections.push(`# Launcher: ${spec.launcherType}`); - sections.push(`# Mode: ${spec.submissionMode}`); - sections.push(""); - - // Resources - sections.push("## Resources"); - sections.push(`GPU: ${spec.resources.gpu}${spec.resources.gpuType ? ` (${spec.resources.gpuType})` : ""}`); - sections.push(`CPU: ${spec.resources.cpu}`); - sections.push(`Memory: ${spec.resources.memoryMb} MB`); - sections.push(`Walltime: ${spec.resources.walltime}`); - sections.push(`Private: ${spec.resources.privateMachine}`); - sections.push(""); - - // Data - if (spec.dataSources.length > 0) { - sections.push("## Data Sources"); - for (const ds of spec.dataSources) { - sections.push(`- ${ds.name}: ${ds.source}://${ds.identifier} → ${ds.cachePath}`); - } - sections.push(""); - } - - // Preprocessing - if (spec.preprocessing.enabled) { - sections.push("## Preprocessing"); - for (const step of spec.preprocessing.steps) { - sections.push(` ${step.order}. ${step.name} (${step.type}): ${step.description}`); - } - sections.push(` Output: ${spec.preprocessing.outputPath}`); - sections.push(""); - } - - // Commands - sections.push("## Commands"); - for (const cmd of spec.commands) { - sections.push(` [${cmd.stage}] ${cmd.name}: ${cmd.command} ${cmd.args.join(" ")}`); - } - sections.push(""); - - // Environment - sections.push("## Environment"); - if (spec.environment.modules.length > 0) sections.push(` Modules: ${spec.environment.modules.join(", ")}`); - if (spec.environment.condaEnv) sections.push(` Conda: ${spec.environment.condaEnv}`); - sections.push(` WorkDir: ${spec.environment.workingDir}`); - sections.push(""); - - // Outputs - sections.push("## Outputs"); - sections.push(` Base: ${spec.outputs.baseDir}`); - sections.push(` Checkpoints: ${spec.outputs.checkpointDir}`); - sections.push(` Logs: ${spec.outputs.logDir}`); - sections.push(` Metrics: ${spec.outputs.metricsDir}`); - - return sections.join("\n"); -} - -// ------------------------------------------------------------------- -// Mock adapter — for testing without a real cluster -// ------------------------------------------------------------------- - -let mockJobCounter = 0; -const mockJobStore = new Map<string, { status: JobStatus; spec: ExperimentSpec }>(); - -export function resetMockState(): void { - mockJobCounter = 0; - mockJobStore.clear(); -} - -export class MockSubmissionAdapter implements SubmissionAdapter { - readonly name = "mock"; - readonly launcherType: LauncherType = "rjob"; - - /** Simulated latency in ms. */ - private latencyMs: number; - - /** Whether submissions should fail. */ - private shouldFail: boolean; - - constructor(opts?: { latencyMs?: number; shouldFail?: boolean; launcherType?: LauncherType }) { - this.latencyMs = opts?.latencyMs ?? 0; - this.shouldFail = opts?.shouldFail ?? false; - if (opts?.launcherType) { - (this as { launcherType: LauncherType }).launcherType = opts.launcherType; - } - } - - renderSpec(spec: ExperimentSpec): string { - return renderJobSpec(spec); - } - - async submit(spec: ExperimentSpec, mode: SubmissionMode): Promise<JobSubmissionResult> { - if (this.latencyMs > 0) { - await new Promise(r => setTimeout(r, this.latencyMs)); - } - - if (this.shouldFail) { - return { - success: false, - jobId: null, - message: "Mock submission failed (configured to fail)", - submittedAt: new Date().toISOString(), - mode, - renderedSpec: this.renderSpec(spec), - metadata: { adapter: "mock", error: "configured_failure" }, - }; - } - - mockJobCounter++; - const jobId = `mock-job-${mockJobCounter}`; - mockJobStore.set(jobId, { status: "queued", spec }); - - return { - success: true, - jobId, - message: mode === "dry_run" - ? `Dry-run: would submit ${spec.name} via ${spec.launcherType}` - : `Mock submitted: ${spec.name} → ${jobId}`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: this.renderSpec(spec), - metadata: { adapter: "mock", jobId, scale: spec.scale }, - }; - } - - async queryStatus(jobId: string): Promise<JobStatusResult> { - const entry = mockJobStore.get(jobId); - if (!entry) { - return { - jobId, - status: "unknown", - message: "Job not found in mock store", - queriedAt: new Date().toISOString(), - }; - } - - // Simulate progression: queued → running → completed - if (entry.status === "queued") { - entry.status = "running"; - } else if (entry.status === "running") { - entry.status = "completed"; - } - - return { - jobId, - status: entry.status, - exitCode: entry.status === "completed" ? 0 : undefined, - runningTimeSec: entry.status === "running" ? 120 : entry.status === "completed" ? 3600 : undefined, - queriedAt: new Date().toISOString(), - }; - } - - async cancel(jobId: string): Promise<{ success: boolean; message: string }> { - const entry = mockJobStore.get(jobId); - if (!entry) return { success: false, message: "Job not found" }; - entry.status = "cancelled"; - return { success: true, message: `Cancelled ${jobId}` }; - } - - async fetchLogs(jobId: string): Promise<JobLogResult> { - const entry = mockJobStore.get(jobId); - return { - jobId, - stdout: entry ? `[mock] Training completed for ${entry.spec.name}\nFinal loss: 0.05\nAccuracy: 0.85` : "", - stderr: "", - truncated: false, - fetchedAt: new Date().toISOString(), - }; - } - - async fetchOutputs(outputDir: string): Promise<JobOutputResult> { - return { - jobId: outputDir, - files: [ - { path: `${outputDir}/metrics.json`, sizeBytes: 1024, isMetrics: true }, - { path: `${outputDir}/model.pt`, sizeBytes: 1_000_000, isMetrics: false }, - ], - metrics: { accuracy: 0.85, loss: 0.05, f1: 0.83 }, - metricsRaw: JSON.stringify({ accuracy: 0.85, loss: 0.05, f1: 0.83 }), - fetchedAt: new Date().toISOString(), - }; - } -} - -// ------------------------------------------------------------------- -// rjob adapter — for real cluster submission -// ------------------------------------------------------------------- - -export class RJobSubmissionAdapter implements SubmissionAdapter { - readonly name = "rjob"; - readonly launcherType: LauncherType = "rjob"; - private chargedGroup: string; - private defaultMounts: MountSpec[]; - - constructor(opts: { chargedGroup: string; defaultMounts?: MountSpec[] }) { - this.chargedGroup = opts.chargedGroup; - this.defaultMounts = opts.defaultMounts ?? []; - } - - renderSpec(spec: ExperimentSpec): string { - const manifest = specToRJobManifest(spec); - manifest.chargedGroup = this.chargedGroup; - if (manifest.mounts.length === 0) manifest.mounts = this.defaultMounts; - return rjobToCommand(manifest); - } - - async submit(spec: ExperimentSpec, mode: SubmissionMode): Promise<JobSubmissionResult> { - const manifest = specToRJobManifest(spec); - manifest.chargedGroup = this.chargedGroup; - if (manifest.mounts.length === 0) manifest.mounts = this.defaultMounts; - - const rendered = rjobToCommand(manifest); - - if (mode === "dry_run") { - return { - success: true, - jobId: null, - message: `Dry-run: rjob spec rendered but not submitted`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "rjob", manifest }, - }; - } - - if (mode === "mock") { - return new MockSubmissionAdapter().submit(spec, mode); - } - - // Real submission: exec rjob command - try { - const { execSync } = await import("child_process"); - const output = execSync(rendered, { - encoding: "utf-8", - timeout: 30_000, - }); - - // Parse job ID from output (format varies by cluster) - const jobIdMatch = output.match(/job[_\s-]?(?:id)?[:\s]+(\S+)/i) ?? - output.match(/(\d{5,})/); - const jobId = jobIdMatch ? jobIdMatch[1] : `rjob-${Date.now()}`; - - return { - success: true, - jobId, - message: `Submitted via rjob: ${jobId}`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "rjob", rawOutput: output.slice(0, 500) }, - }; - } catch (error) { - const msg = error instanceof Error ? error.message : "rjob submission failed"; - return { - success: false, - jobId: null, - message: msg, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "rjob", error: msg }, - }; - } - } - - async queryStatus(jobId: string): Promise<JobStatusResult> { - try { - const { execSync } = await import("child_process"); - const output = execSync(`rjob status ${jobId}`, { encoding: "utf-8", timeout: 10_000 }); - const status = parseRJobStatus(output); - return { - jobId, - status, - message: output.trim().slice(0, 200), - queriedAt: new Date().toISOString(), - }; - } catch { - return { - jobId, - status: "unknown", - message: "Failed to query rjob status", - queriedAt: new Date().toISOString(), - }; - } - } - - async cancel(jobId: string): Promise<{ success: boolean; message: string }> { - try { - const { execSync } = await import("child_process"); - execSync(`rjob cancel ${jobId}`, { encoding: "utf-8", timeout: 10_000 }); - return { success: true, message: `Cancelled rjob ${jobId}` }; - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Cancel failed" }; - } - } -} - -function parseRJobStatus(output: string): JobStatus { - const lower = output.toLowerCase(); - if (lower.includes("completed") || lower.includes("finished")) return "completed"; - if (lower.includes("running")) return "running"; - if (lower.includes("pending") || lower.includes("queued")) return "queued"; - if (lower.includes("failed") || lower.includes("error")) return "failed"; - if (lower.includes("cancelled") || lower.includes("canceled")) return "cancelled"; - return "unknown"; -} - -// ------------------------------------------------------------------- -// rlaunch adapter — for interactive GPU machine requests -// ------------------------------------------------------------------- - -export class RLaunchSubmissionAdapter implements SubmissionAdapter { - readonly name = "rlaunch"; - readonly launcherType: LauncherType = "rlaunch"; - private chargedGroup: string; - private defaultMounts: MountSpec[]; - - constructor(opts: { chargedGroup: string; defaultMounts?: MountSpec[] }) { - this.chargedGroup = opts.chargedGroup; - this.defaultMounts = opts.defaultMounts ?? []; - } - - renderSpec(spec: ExperimentSpec): string { - const manifest = specToRLaunchManifest(spec); - manifest.chargedGroup = this.chargedGroup; - if (manifest.mounts.length === 0) manifest.mounts = this.defaultMounts; - return rlaunchToCommand(manifest); - } - - async submit(spec: ExperimentSpec, mode: SubmissionMode): Promise<JobSubmissionResult> { - const manifest = specToRLaunchManifest(spec); - manifest.chargedGroup = this.chargedGroup; - if (manifest.mounts.length === 0) manifest.mounts = this.defaultMounts; - const rendered = rlaunchToCommand(manifest); - - if (mode === "dry_run") { - return { - success: true, - jobId: null, - message: "Dry-run: rlaunch spec rendered but not submitted", - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "rlaunch", manifest }, - }; - } - - if (mode === "mock") { - return new MockSubmissionAdapter({ launcherType: "rlaunch" }).submit(spec, mode); - } - - try { - const { execSync } = await import("child_process"); - const output = execSync(rendered, { encoding: "utf-8", timeout: 60_000 }); - const jobId = `rlaunch-${Date.now()}`; - return { - success: true, - jobId, - message: `Launched via rlaunch: ${jobId}`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "rlaunch", rawOutput: output.slice(0, 500) }, - }; - } catch (error) { - return { - success: false, - jobId: null, - message: error instanceof Error ? error.message : "rlaunch failed", - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "rlaunch" }, - }; - } - } - - async queryStatus(jobId: string): Promise<JobStatusResult> { - // rlaunch is interactive; status checking is limited - return { - jobId, - status: "running", - message: "rlaunch sessions are interactive — status check is best-effort", - queriedAt: new Date().toISOString(), - }; - } - - async cancel(_jobId: string): Promise<{ success: boolean; message: string }> { - return { success: false, message: "rlaunch sessions must be terminated manually" }; - } -} - -// ------------------------------------------------------------------- -// Adapter registry -// ------------------------------------------------------------------- - -const adapterRegistry = new Map<LauncherType, () => SubmissionAdapter>(); - -export function registerSubmissionAdapter( - launcherType: LauncherType, - factory: () => SubmissionAdapter, -): void { - adapterRegistry.set(launcherType, factory); -} - -export function getSubmissionAdapter(launcherType: LauncherType): SubmissionAdapter { - const factory = adapterRegistry.get(launcherType); - if (factory) return factory(); - // Fallback to mock - return new MockSubmissionAdapter({ launcherType }); -} - -// Pre-register adapters -registerSubmissionAdapter("rjob", () => new RJobSubmissionAdapter({ - chargedGroup: "ai4sdata_gpu", -})); -registerSubmissionAdapter("rlaunch", () => new RLaunchSubmissionAdapter({ - chargedGroup: "ai4sdata_gpu", -})); -registerSubmissionAdapter("local_shell", () => new MockSubmissionAdapter({ launcherType: "local_shell" })); diff --git a/src/lib/deep-research/exec-manifest.ts b/src/lib/deep-research/exec-manifest.ts deleted file mode 100644 index 994d0c17..00000000 --- a/src/lib/deep-research/exec-manifest.ts +++ /dev/null @@ -1,189 +0,0 @@ -// ============================================================= -// Execution Pipeline — Experiment Manifest Tracking -// ============================================================= -// Unified manifest system for recording exact experiment state -// for reproducibility and auditability. - -import type { - ExperimentSpec, - ExperimentManifest, - ExperimentStatus, - DatasetAcquisitionResult, - PreprocessingRunResult, - JobSubmissionResult, -} from "./types"; - -// ------------------------------------------------------------------- -// Manifest creation -// ------------------------------------------------------------------- - -/** - * Create an initial experiment manifest from a spec. - * This records the starting state before execution. - */ -export function createExperimentManifest(spec: ExperimentSpec): ExperimentManifest { - return { - experimentId: spec.experimentId, - sessionId: spec.sessionId, - createdAt: new Date().toISOString(), - - datasets: spec.dataSources.map(ds => ({ - sourceId: ds.id, - identifier: ds.identifier, - revision: ds.revision, - localPath: ds.cachePath, - })), - - preprocessingConfig: spec.preprocessing, - - executionConfig: { - resources: spec.resources, - environment: spec.environment, - commands: spec.commands, - launcherType: spec.launcherType, - }, - - outputPaths: spec.outputs, - status: spec.status, - }; -} - -/** - * Update manifest with dataset acquisition results. - */ -export function updateManifestWithDatasets( - manifest: ExperimentManifest, - results: DatasetAcquisitionResult[], -): ExperimentManifest { - return { - ...manifest, - datasets: manifest.datasets.map(ds => { - const result = results.find(r => r.sourceId === ds.sourceId); - if (!result) return ds; - return { - ...ds, - localPath: result.localPath, - checksum: result.checksum, - }; - }), - status: results.every(r => r.status === "ready" || r.status === "skipped") - ? "data_ready" - : results.some(r => r.status === "failed") - ? "failed" - : "data_downloading", - }; -} - -/** - * Update manifest with preprocessing results. - */ -export function updateManifestWithPreprocessing( - manifest: ExperimentManifest, - result: PreprocessingRunResult, -): ExperimentManifest { - return { - ...manifest, - status: result.overallStatus === "completed" || result.overallStatus === "skipped" - ? "preprocess_ready" - : "failed", - }; -} - -/** - * Update manifest with job submission result. - */ -export function updateManifestWithSubmission( - manifest: ExperimentManifest, - result: JobSubmissionResult, -): ExperimentManifest { - return { - ...manifest, - jobSubmission: result, - status: result.success - ? (result.mode === "dry_run" ? "dry_run" : "submitted") - : "failed", - startedAt: result.success ? result.submittedAt : undefined, - }; -} - -/** - * Mark manifest as completed with evaluation summary. - */ -export function finalizeManifest( - manifest: ExperimentManifest, - status: ExperimentStatus, - evaluationSummary?: Record<string, unknown>, -): ExperimentManifest { - return { - ...manifest, - status, - evaluationSummary, - completedAt: new Date().toISOString(), - }; -} - -// ------------------------------------------------------------------- -// Manifest serialization -// ------------------------------------------------------------------- - -/** - * Render manifest as human-readable text. - */ -export function renderManifestSummary(manifest: ExperimentManifest): string { - const lines: string[] = []; - lines.push(`# Experiment Manifest: ${manifest.experimentId}`); - lines.push(`Session: ${manifest.sessionId}`); - lines.push(`Status: ${manifest.status}`); - lines.push(`Created: ${manifest.createdAt}`); - if (manifest.completedAt) lines.push(`Completed: ${manifest.completedAt}`); - lines.push(""); - - lines.push("## Datasets"); - for (const ds of manifest.datasets) { - lines.push(` - ${ds.sourceId}: ${ds.identifier} → ${ds.localPath}${ds.checksum ? ` (${ds.checksum})` : ""}`); - } - lines.push(""); - - lines.push("## Preprocessing"); - lines.push(` Enabled: ${manifest.preprocessingConfig.enabled}`); - lines.push(` Steps: ${manifest.preprocessingConfig.steps.length}`); - lines.push(` Output: ${manifest.preprocessingConfig.outputPath}`); - lines.push(""); - - lines.push("## Execution"); - lines.push(` Launcher: ${manifest.executionConfig.launcherType}`); - lines.push(` GPU: ${manifest.executionConfig.resources.gpu}`); - lines.push(` Memory: ${manifest.executionConfig.resources.memoryMb} MB`); - lines.push(` Commands: ${manifest.executionConfig.commands.length}`); - lines.push(""); - - if (manifest.jobSubmission) { - lines.push("## Job Submission"); - lines.push(` Job ID: ${manifest.jobSubmission.jobId ?? "N/A"}`); - lines.push(` Mode: ${manifest.jobSubmission.mode}`); - lines.push(` Success: ${manifest.jobSubmission.success}`); - lines.push(` Message: ${manifest.jobSubmission.message}`); - lines.push(""); - } - - lines.push("## Outputs"); - lines.push(` Base: ${manifest.outputPaths.baseDir}`); - lines.push(` Checkpoints: ${manifest.outputPaths.checkpointDir}`); - lines.push(` Logs: ${manifest.outputPaths.logDir}`); - lines.push(` Metrics: ${manifest.outputPaths.metricsDir}`); - - if (manifest.evaluationSummary) { - lines.push(""); - lines.push("## Evaluation"); - lines.push(` ${JSON.stringify(manifest.evaluationSummary, null, 2)}`); - } - - return lines.join("\n"); -} - -/** - * Serialize manifest to JSON (for storage as artifact). - */ -export function manifestToArtifactContent(manifest: ExperimentManifest): Record<string, unknown> { - return manifest as unknown as Record<string, unknown>; -} diff --git a/src/lib/deep-research/exec-pipeline.ts b/src/lib/deep-research/exec-pipeline.ts deleted file mode 100644 index 9d23f05c..00000000 --- a/src/lib/deep-research/exec-pipeline.ts +++ /dev/null @@ -1,640 +0,0 @@ -// ============================================================= -// Execution Pipeline — Main Orchestrator -// ============================================================= -// Wires the full path: plan → data → preprocess → job spec → submit. -// Produces an ExperimentManifest at every stage for tracking. - -import type { - ExperimentSpec, - ExperimentManifest, - ExperimentStatus, - ExecutionPipelineConfig, - DatasetAcquisitionResult, - PreprocessingRunResult, - JobSubmissionResult, - DryRunResult, - ValidationPlan, - DeepResearchSession, - ExperimentGroup, - WorkerFanoutPlan, - ExecutionValidationResult, - ExperimentAnalysisResult, - AggregatedResult, -} from "./types"; -import { resolveConfig, resolveResources, resolveEnvironment, experimentOutputPath, preprocessingOutputPath, datasetCachePath } from "./exec-config"; -import { MockSubmissionAdapter, getSubmissionAdapter, renderJobSpec } from "./exec-job-submitter"; -import type { SubmissionAdapter } from "./exec-job-submitter"; -import { buildDatasetAcquisitionPlan, executeDatasetAcquisition } from "./exec-dataset-manager"; -import { executePreprocessingPipeline } from "./exec-preprocess-runner"; -import { createExperimentManifest, updateManifestWithDatasets, updateManifestWithPreprocessing, updateManifestWithSubmission, finalizeManifest } from "./exec-manifest"; -import { checkExecutionReadiness, generateDryRun } from "./exec-readiness"; -import { - buildExperimentGroup, - submitGroupWorkers, - pollWorkerStatuses, - collectWorkerResults, -} from "./execution-round-manager"; -import { aggregateWorkerResults, computeGroupStatus } from "./worker-aggregator"; -import { validateExperimentResults } from "./execution-validator"; -import { analyzeExperimentFailure } from "./experiment-analysis"; - -// ------------------------------------------------------------------- -// Spec builder — converts a validation plan to an ExperimentSpec -// ------------------------------------------------------------------- - -let specCounter = 0; - -export function resetSpecCounter(): void { - specCounter = 0; -} - -/** - * Build an ExperimentSpec from a ValidationPlan and session context. - * This is the bridge between the research planning layer and the execution layer. - */ -export function buildExperimentSpec( - session: DeepResearchSession, - validationPlan: ValidationPlan, - config?: Partial<ExecutionPipelineConfig>, - overrides?: Partial<ExperimentSpec>, -): ExperimentSpec { - const pipelineConfig = resolveConfig(config); - specCounter++; - const experimentId = overrides?.experimentId ?? `exp-${session.id}-${specCounter}`; - const outputBase = experimentOutputPath(pipelineConfig, experimentId); - const preprocBase = preprocessingOutputPath(pipelineConfig, experimentId); - - // Extract datasets from validation plan - const dataSources = validationPlan.datasets.map((ds, i) => ({ - id: `ds-${i}`, - name: ds, - source: inferDataSource(ds), - identifier: ds, - estimatedSizeGb: 1, - cachePath: datasetCachePath(pipelineConfig, `ds-${i}-${sanitize(ds)}`), - })); - - // Extract commands from validation steps - const commands = validationPlan.steps.map((step, i) => ({ - name: step.description, - command: step.command ?? step.scriptPath ?? "echo", - args: step.command ? [] : [step.scriptPath ?? "no-command"], - stage: inferStage(step.description) as "setup" | "train" | "eval" | "postprocess", - dependsOn: i > 0 ? [validationPlan.steps[i - 1].description] : [], - })); - - const resources = resolveResources(pipelineConfig.defaultResources, { - gpu: validationPlan.requiredResources.gpu, - memoryMb: validationPlan.requiredResources.memoryMb, - cpu: validationPlan.requiredResources.cpu, - }); - - const environment = resolveEnvironment(pipelineConfig.defaultEnvironment ?? {}, { - workingDir: outputBase, - }); - - return { - experimentId, - sessionId: session.id, - name: validationPlan.objective, - description: validationPlan.hypothesis, - scale: "pilot", - status: "planning", - taskType: inferTaskType(validationPlan), - models: [], - dataSources, - preprocessing: { - enabled: dataSources.length > 0, - steps: buildDefaultPreprocessingSteps(), - outputPath: preprocBase, - outputFormat: "jsonl", - skipIfCached: pipelineConfig.skipExistingPreprocessing, - }, - commands, - resources, - mounts: pipelineConfig.defaultMounts, - environment, - outputs: { - baseDir: outputBase, - checkpointDir: `${outputBase}/checkpoints`, - logDir: `${outputBase}/logs`, - metricsDir: `${outputBase}/metrics`, - artifactPatterns: ["*.json", "*.pt", "*.safetensors", "*.log"], - }, - retryPolicy: pipelineConfig.defaultRetryPolicy, - submissionMode: "dry_run", - launcherType: pipelineConfig.defaultLauncherType, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - ...overrides, - }; -} - -function inferDataSource(ds: string): "huggingface" | "github" | "url" | "local" { - if (ds.startsWith("/") || ds.startsWith("./")) return "local"; - if (ds.includes("github.com")) return "github"; - if (ds.includes("huggingface.co") || ds.match(/^[\w-]+\/[\w-]+$/) || ds.includes("hf://")) return "huggingface"; - if (ds.startsWith("http")) return "url"; - return "huggingface"; -} - -function inferStage(description: string): string { - const lower = description.toLowerCase(); - if (lower.includes("train") || lower.includes("fine-tune") || lower.includes("finetune")) return "train"; - if (lower.includes("eval") || lower.includes("test") || lower.includes("benchmark")) return "eval"; - if (lower.includes("setup") || lower.includes("install") || lower.includes("download")) return "setup"; - return "train"; -} - -function inferTaskType(plan: ValidationPlan): string { - const text = `${plan.objective} ${plan.hypothesis}`.toLowerCase(); - if (text.includes("train")) return "training"; - if (text.includes("fine-tune") || text.includes("finetune")) return "fine_tuning"; - if (text.includes("eval")) return "evaluation"; - if (text.includes("preprocess")) return "preprocessing"; - return "training"; -} - -function sanitize(s: string): string { - return s.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40); -} - -function buildDefaultPreprocessingSteps(): ExperimentSpec["preprocessing"]["steps"] { - return [ - { - order: 1, - name: "validate", - type: "validate", - config: { requiredFields: ["text"] }, - description: "Validate input records have required fields", - }, - { - order: 2, - name: "dedup", - type: "dedup", - config: { method: "exact", fields: ["text"] }, - description: "Remove exact duplicates", - }, - { - order: 3, - name: "filter", - type: "filter", - config: { field: "text", minLength: 10 }, - description: "Filter records with minimum text length", - }, - ]; -} - -// ------------------------------------------------------------------- -// Pipeline execution — the main orchestration path -// ------------------------------------------------------------------- - -export interface PipelineResult { - experimentId: string; - manifest: ExperimentManifest; - dataResults: DatasetAcquisitionResult[]; - preprocessingResult: PreprocessingRunResult | null; - submissionResult: JobSubmissionResult | null; - dryRun: DryRunResult | null; - status: ExperimentStatus; - log: PipelineLogEntry[]; -} - -export interface PipelineLogEntry { - timestamp: string; - stage: string; - message: string; - level: "info" | "warn" | "error"; -} - -/** - * Execute the full pipeline: data → preprocess → submit. - * - * If submissionMode is "dry_run", stops before actual submission. - * If submissionMode is "mock", uses the mock adapter for testing. - */ -export async function executePipeline( - spec: ExperimentSpec, - config?: Partial<ExecutionPipelineConfig>, -): Promise<PipelineResult> { - const pipelineConfig = resolveConfig(config); - const log: PipelineLogEntry[] = []; - const addLog = (stage: string, message: string, level: PipelineLogEntry["level"] = "info") => { - log.push({ timestamp: new Date().toISOString(), stage, message, level }); - }; - - // Initialize manifest - let manifest = createExperimentManifest(spec); - addLog("init", `Pipeline started for ${spec.name} (${spec.scale})`); - - // Step 1: Readiness check - const readiness = checkExecutionReadiness(spec, pipelineConfig); - if (!readiness.ready) { - addLog("readiness", `Blocked: ${readiness.blockers.map(b => b.issue).join("; ")}`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults: [], - preprocessingResult: null, - submissionResult: null, - dryRun: null, - status: "failed", - log, - }; - } - addLog("readiness", "All readiness checks passed"); - - // Step 2: Generate dry-run if requested - let dryRun: DryRunResult | null = null; - if (spec.submissionMode === "dry_run") { - dryRun = generateDryRun(spec, pipelineConfig); - addLog("dry_run", `Dry-run generated: ${dryRun.readyToSubmit ? "ready" : "not ready"}`); - if (dryRun.blockers.length > 0) { - addLog("dry_run", `Blockers: ${dryRun.blockers.join("; ")}`, "warn"); - } - manifest = finalizeManifest(manifest, "dry_run"); - return { - experimentId: spec.experimentId, - manifest, - dataResults: [], - preprocessingResult: null, - submissionResult: null, - dryRun, - status: "dry_run", - log, - }; - } - - // Step 3: Dataset acquisition - addLog("data", `Acquiring ${spec.dataSources.length} dataset(s)...`); - let dataResults: DatasetAcquisitionResult[] = []; - try { - dataResults = await executeDatasetAcquisition(spec, pipelineConfig); - manifest = updateManifestWithDatasets(manifest, dataResults); - - const failed = dataResults.filter(r => r.status === "failed"); - if (failed.length > 0) { - addLog("data", `${failed.length} dataset(s) failed to download`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults, - preprocessingResult: null, - submissionResult: null, - dryRun: null, - status: "failed", - log, - }; - } - addLog("data", `All datasets acquired (${dataResults.filter(r => r.status === "ready").length} downloaded, ${dataResults.filter(r => r.status === "skipped").length} cached)`); - } catch (error) { - addLog("data", `Dataset acquisition failed: ${error instanceof Error ? error.message : "unknown"}`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults, - preprocessingResult: null, - submissionResult: null, - dryRun: null, - status: "failed", - log, - }; - } - - // Step 4: Preprocessing - let preprocessingResult: PreprocessingRunResult | null = null; - if (spec.preprocessing.enabled && spec.preprocessing.steps.length > 0) { - addLog("preprocess", `Running ${spec.preprocessing.steps.length} preprocessing step(s)...`); - try { - preprocessingResult = await executePreprocessingPipeline(spec, pipelineConfig); - manifest = updateManifestWithPreprocessing(manifest, preprocessingResult); - - if (preprocessingResult.overallStatus === "failed") { - const failedStep = preprocessingResult.steps.find(s => s.status === "failed"); - addLog("preprocess", `Preprocessing failed at step "${failedStep?.stepName}": ${failedStep?.error}`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults, - preprocessingResult, - submissionResult: null, - dryRun: null, - status: "failed", - log, - }; - } - addLog("preprocess", `Preprocessing completed (${preprocessingResult.steps.filter(s => s.status === "completed").length} run, ${preprocessingResult.steps.filter(s => s.status === "skipped").length} skipped)`); - } catch (error) { - addLog("preprocess", `Preprocessing failed: ${error instanceof Error ? error.message : "unknown"}`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults, - preprocessingResult, - submissionResult: null, - dryRun: null, - status: "failed", - log, - }; - } - } else { - addLog("preprocess", "No preprocessing required"); - } - - // Step 5: Job submission - addLog("submit", `Submitting via ${spec.launcherType} (mode: ${spec.submissionMode})`); - const adapter = spec.submissionMode === "mock" - ? new MockSubmissionAdapter() - : getSubmissionAdapter(spec.launcherType); - - let submissionResult: JobSubmissionResult; - try { - submissionResult = await adapter.submit(spec, spec.submissionMode); - manifest = updateManifestWithSubmission(manifest, submissionResult); - - if (!submissionResult.success) { - addLog("submit", `Submission failed: ${submissionResult.message}`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults, - preprocessingResult, - submissionResult, - dryRun: null, - status: "failed", - log, - }; - } - addLog("submit", `Job submitted: ${submissionResult.jobId ?? "N/A"} (${submissionResult.mode})`); - } catch (error) { - addLog("submit", `Submission error: ${error instanceof Error ? error.message : "unknown"}`, "error"); - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, "failed"), - dataResults, - preprocessingResult, - submissionResult: null, - dryRun: null, - status: "failed", - log, - }; - } - - const finalStatus: ExperimentStatus = submissionResult.mode === "mock" ? "submitted" : "submitted"; - addLog("complete", `Pipeline completed — status: ${finalStatus}`); - - return { - experimentId: spec.experimentId, - manifest: finalizeManifest(manifest, finalStatus), - dataResults, - preprocessingResult, - submissionResult, - dryRun: null, - status: finalStatus, - log, - }; -} - -// ------------------------------------------------------------------- -// Convenience entry points -// ------------------------------------------------------------------- - -/** - * Generate an execution plan from a session's validation plan (dry-run only). - */ -export function generateExecutionSpec( - session: DeepResearchSession, - validationPlan: ValidationPlan, - config?: Partial<ExecutionPipelineConfig>, -): { spec: ExperimentSpec; dryRun: DryRunResult; readiness: ReturnType<typeof checkExecutionReadiness> } { - const spec = buildExperimentSpec(session, validationPlan, config, { - submissionMode: "dry_run", - }); - const pipelineConfig = resolveConfig(config); - const readiness = checkExecutionReadiness(spec, pipelineConfig); - const dryRun = generateDryRun(spec, pipelineConfig); - return { spec, dryRun, readiness }; -} - -/** - * Preview what datasets need to be fetched. - */ -export function previewDataAcquisition( - spec: ExperimentSpec, - config?: Partial<ExecutionPipelineConfig>, -) { - const pipelineConfig = resolveConfig(config); - return buildDatasetAcquisitionPlan(spec, pipelineConfig); -} - -/** - * Inspect the rendered job spec without submitting. - */ -export function inspectJobSpec(spec: ExperimentSpec): string { - return renderJobSpec(spec); -} - -// ------------------------------------------------------------------- -// Grouped Execution Pipeline — Multi-worker with validation loop -// ------------------------------------------------------------------- - -export interface GroupedPipelineResult { - experimentId: string; - group: ExperimentGroup; - aggregated: AggregatedResult | null; - validationResult: ExecutionValidationResult | null; - analysisResult: ExperimentAnalysisResult | null; - dataResults: DatasetAcquisitionResult[]; - preprocessingResult: PreprocessingRunResult | null; - status: "completed" | "failed" | "partially_failed" | "validated" | "inconclusive"; - log: PipelineLogEntry[]; -} - -/** - * Execute a full grouped pipeline: - * 1. Acquire data (shared across workers) - * 2. Preprocess (shared) - * 3. Build worker group from fanout plan - * 4. Submit all workers - * 5. Poll until all complete - * 6. Collect results - * 7. Aggregate - * 8. Validate - * 9. Analyze if failed - */ -export async function executeGroupedPipeline( - fanoutPlan: WorkerFanoutPlan, - config?: Partial<ExecutionPipelineConfig>, - adapter?: SubmissionAdapter, -): Promise<GroupedPipelineResult> { - const pipelineConfig = resolveConfig(config); - const log: PipelineLogEntry[] = []; - const addLog = (stage: string, message: string, level: PipelineLogEntry["level"] = "info") => { - log.push({ timestamp: new Date().toISOString(), stage, message, level }); - }; - - const parentSpec = fanoutPlan.parentSpec; - addLog("init", `Grouped pipeline started: ${parentSpec.name} (${fanoutPlan.totalWorkers} workers, strategy=${fanoutPlan.strategy})`); - - // Step 1: Readiness check - const readiness = checkExecutionReadiness(parentSpec, pipelineConfig); - if (!readiness.ready) { - addLog("readiness", `Blocked: ${readiness.blockers.map(b => b.issue).join("; ")}`, "error"); - return makeFailedGroupResult(parentSpec.experimentId, [], null, log); - } - - // Step 2: Data acquisition (shared) - let dataResults: DatasetAcquisitionResult[] = []; - if (parentSpec.submissionMode !== "dry_run") { - addLog("data", `Acquiring ${parentSpec.dataSources.length} dataset(s)...`); - try { - dataResults = await executeDatasetAcquisition(parentSpec, pipelineConfig); - const failed = dataResults.filter(r => r.status === "failed"); - if (failed.length > 0) { - addLog("data", `${failed.length} dataset(s) failed`, "error"); - return makeFailedGroupResult(parentSpec.experimentId, dataResults, null, log); - } - addLog("data", `All datasets acquired`); - } catch (error) { - addLog("data", `Dataset acquisition failed: ${error instanceof Error ? error.message : "unknown"}`, "error"); - return makeFailedGroupResult(parentSpec.experimentId, dataResults, null, log); - } - } - - // Step 3: Preprocessing (shared) - let preprocessingResult: PreprocessingRunResult | null = null; - if (parentSpec.preprocessing.enabled && parentSpec.preprocessing.steps.length > 0 && parentSpec.submissionMode !== "dry_run") { - addLog("preprocess", `Running preprocessing...`); - try { - preprocessingResult = await executePreprocessingPipeline(parentSpec, pipelineConfig); - if (preprocessingResult.overallStatus === "failed") { - addLog("preprocess", "Preprocessing failed", "error"); - return makeFailedGroupResult(parentSpec.experimentId, dataResults, preprocessingResult, log); - } - addLog("preprocess", "Preprocessing completed"); - } catch (error) { - addLog("preprocess", `Preprocessing error: ${error instanceof Error ? error.message : "unknown"}`, "error"); - return makeFailedGroupResult(parentSpec.experimentId, dataResults, preprocessingResult, log); - } - } - - // Step 4: Build experiment group - const group = buildExperimentGroup(parentSpec.sessionId, 1, fanoutPlan); - addLog("group", `Created experiment group: ${group.groupId} with ${group.workers.length} workers`); - - // Step 5: Submit workers - const effectiveAdapter = adapter ?? - (parentSpec.submissionMode === "mock" ? new MockSubmissionAdapter() : getSubmissionAdapter(parentSpec.launcherType)); - - let currentGroup = await submitGroupWorkers(group, effectiveAdapter, parentSpec.submissionMode, fanoutPlan.maxParallel); - const submittedCount = currentGroup.workers.filter(w => w.status !== "pending").length; - addLog("submit", `Submitted ${submittedCount}/${currentGroup.workers.length} workers via ${parentSpec.launcherType}`); - - // Step 6: Poll until all workers complete (with timeout protection) - const maxPolls = 100; - let pollCount = 0; - while (pollCount < maxPolls) { - const activeWorkers = currentGroup.workers.filter( - w => w.status === "queued" || w.status === "running" - ); - if (activeWorkers.length === 0) break; - - currentGroup = await pollWorkerStatuses(currentGroup, effectiveAdapter); - - // Submit any newly-unblocked workers (for sequential deps) - const pendingWithReadyDeps = currentGroup.workers.filter(w => { - if (w.status !== "pending") return false; - const deps = currentGroup.dependencyGraph[w.workerId] ?? []; - return deps.every(d => { - const dep = currentGroup.workers.find(ww => ww.workerId === d); - return dep && dep.status === "completed"; - }); - }); - if (pendingWithReadyDeps.length > 0) { - currentGroup = await submitGroupWorkers(currentGroup, effectiveAdapter, parentSpec.submissionMode, fanoutPlan.maxParallel); - } - - pollCount++; - // In real execution, we'd await a delay here; for mock/testing, the poll loop terminates quickly - } - - addLog("monitor", `All workers finished. Poll cycles: ${pollCount}`); - - // Step 7: Collect results - currentGroup = await collectWorkerResults(currentGroup); - const succeeded = currentGroup.workers.filter(w => w.status === "completed").length; - const failed = currentGroup.workers.filter(w => w.status === "failed").length; - addLog("collect", `Results collected: ${succeeded} succeeded, ${failed} failed`); - - // Step 8: Aggregate - currentGroup.status = computeGroupStatus(currentGroup); - const aggregated = aggregateWorkerResults(currentGroup); - currentGroup.aggregatedResult = aggregated; - addLog("aggregate", `Aggregated ${Object.keys(aggregated.metrics).length} metrics from ${aggregated.succeededWorkers} workers`); - - // Step 9: Validate - const validationResult = validateExperimentResults(currentGroup, aggregated); - addLog("validate", `Validation verdict: ${validationResult.verdict} (confidence: ${validationResult.confidenceScore.toFixed(2)})`); - - // Step 10: Analyze if needed - let analysisResult: ExperimentAnalysisResult | null = null; - if (validationResult.verdict !== "pass") { - analysisResult = analyzeExperimentFailure(currentGroup, aggregated, validationResult); - addLog("analyze", `Analysis: ${analysisResult.primaryRecommendation} (top cause: ${analysisResult.rootCauses[0]?.category ?? "unknown"})`); - } - - const finalStatus = validationResult.verdict === "pass" - ? "validated" as const - : validationResult.verdict === "inconclusive" - ? "inconclusive" as const - : currentGroup.status === "partially_failed" - ? "partially_failed" as const - : "failed" as const; - - addLog("complete", `Grouped pipeline finished: ${finalStatus}`); - - return { - experimentId: parentSpec.experimentId, - group: currentGroup, - aggregated, - validationResult, - analysisResult, - dataResults, - preprocessingResult, - status: finalStatus, - log, - }; -} - -function makeFailedGroupResult( - experimentId: string, - dataResults: DatasetAcquisitionResult[], - preprocessingResult: PreprocessingRunResult | null, - log: PipelineLogEntry[], -): GroupedPipelineResult { - return { - experimentId, - group: { - groupId: "failed", - sessionId: "", - roundNumber: 0, - parentSpec: {} as ExperimentSpec, - decompositionStrategy: "custom", - workers: [], - dependencyGraph: {}, - aggregationRules: { metricAggregation: "mean", minSuccessRate: 0, metricsToAggregate: [], computeVariance: false, maxCoefficientOfVariation: null, customAggregator: null }, - validationCriteria: { metricThresholds: [], requiredArtifacts: [], minSuccessfulWorkers: 0, maxVariance: null, baselineRequired: false, baselineMetrics: {}, customConditions: [] }, - status: "failed", - aggregatedResult: null, - createdAt: new Date().toISOString(), - completedAt: null, - }, - aggregated: null, - validationResult: null, - analysisResult: null, - dataResults, - preprocessingResult, - status: "failed", - log, - }; -} diff --git a/src/lib/deep-research/exec-preprocess-runner.ts b/src/lib/deep-research/exec-preprocess-runner.ts deleted file mode 100644 index 1d06d22c..00000000 --- a/src/lib/deep-research/exec-preprocess-runner.ts +++ /dev/null @@ -1,455 +0,0 @@ -// ============================================================= -// Execution Pipeline — Preprocessing Runner -// ============================================================= -// Executes preprocessing pipelines step-by-step with caching, -// skip-if-exists, and per-step tracking. - -import * as crypto from "crypto"; -import type { - ExperimentSpec, - PreprocessingPipelineSpec, - PreprocessingStepSpec, - PreprocessingStepResult, - PreprocessingRunResult, - ExecutionPipelineConfig, -} from "./types"; - -// ------------------------------------------------------------------- -// Config hashing (for cache invalidation) -// ------------------------------------------------------------------- - -/** - * Hash a step's config to detect changes. - */ -export function hashStepConfig(step: PreprocessingStepSpec): string { - const data = JSON.stringify({ - name: step.name, - type: step.type, - config: step.config, - version: step.version ?? "1", - }); - return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16); -} - -/** - * Hash an entire pipeline config. - */ -export function hashPipelineConfig(pipeline: PreprocessingPipelineSpec): string { - const data = JSON.stringify({ - steps: pipeline.steps.map(s => ({ - name: s.name, - type: s.type, - config: s.config, - version: s.version, - })), - outputFormat: pipeline.outputFormat, - }); - return crypto.createHash("sha256").update(data).digest("hex").slice(0, 16); -} - -// ------------------------------------------------------------------- -// Step execution helpers -// ------------------------------------------------------------------- - -export type FileExistenceChecker = (path: string) => boolean; -export type CommandRunner = (cmd: string, opts?: { timeout?: number }) => Promise<{ stdout: string; exitCode: number }>; -export type HashReader = (path: string) => string | null; - -let fileExists: FileExistenceChecker = defaultFileExists; -let runCommand: CommandRunner = defaultRunCommand; -let readHash: HashReader = defaultReadHash; - -export function setFileChecker(checker: FileExistenceChecker): void { fileExists = checker; } -export function setCommandRunner(runner: CommandRunner): void { runCommand = runner; } -export function setHashReader(reader: HashReader): void { readHash = reader; } - -export function resetRunnerOverrides(): void { - fileExists = defaultFileExists; - runCommand = defaultRunCommand; - readHash = defaultReadHash; -} - -function defaultFileExists(path: string): boolean { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const fs = require("fs"); - return fs.existsSync(path); - } catch { return false; } -} - -async function defaultRunCommand( - cmd: string, - opts?: { timeout?: number }, -): Promise<{ stdout: string; exitCode: number }> { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { exec } = require("child_process"); - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { promisify } = require("util"); - const execAsync = promisify(exec); - try { - const { stdout } = await execAsync(cmd, { - timeout: opts?.timeout ?? 1800_000, - maxBuffer: 10 * 1024 * 1024, - }); - return { stdout: stdout ?? "", exitCode: 0 }; - } catch (error: unknown) { - const err = error as { code?: number; stderr?: string; message?: string }; - return { stdout: err.stderr ?? err.message ?? "Unknown error", exitCode: err.code ?? 1 }; - } -} - -function defaultReadHash(path: string): string | null { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const fs = require("fs"); - return fs.readFileSync(path, "utf-8").trim(); - } catch { return null; } -} - -// ------------------------------------------------------------------- -// Step command builder -// ------------------------------------------------------------------- - -/** - * Build the shell command for a single preprocessing step. - */ -export function buildStepCommand( - step: PreprocessingStepSpec, - inputPath: string, - outputPath: string, - outputFormat: string, -): string { - switch (step.type) { - case "filter": - return buildFilterCommand(step, inputPath, outputPath, outputFormat); - case "transform": - return buildTransformCommand(step, inputPath, outputPath, outputFormat); - case "dedup": - return buildDedupCommand(step, inputPath, outputPath); - case "split": - return buildSplitCommand(step, inputPath, outputPath); - case "sample": - return buildSampleCommand(step, inputPath, outputPath); - case "tokenize": - return buildTokenizeCommand(step, inputPath, outputPath); - case "validate": - return buildValidateCommand(step, inputPath, outputPath); - case "custom": - return buildCustomCommand(step, inputPath, outputPath); - default: - return `echo "Unknown step type: ${step.type}" && exit 1`; - } -} - -function buildFilterCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string, _format: string): string { - const field = (step.config.field as string) ?? "text"; - const minLen = (step.config.minLength as number) ?? 0; - const maxLen = (step.config.maxLength as number) ?? 1_000_000; - return `python3 -c " -import json, os -os.makedirs(os.path.dirname('${outputPath}'), exist_ok=True) -kept, dropped = 0, 0 -with open('${inputPath}') as fin, open('${outputPath}', 'w') as fout: - for line in fin: - rec = json.loads(line) - val = rec.get('${field}', '') - if ${minLen} <= len(val) <= ${maxLen}: - fout.write(line) - kept += 1 - else: - dropped += 1 -print(f'Filter: kept={kept}, dropped={dropped}') -"`; -} - -function buildTransformCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string, _format: string): string { - const operations = (step.config.operations as string[]) ?? []; - const opStr = operations.map(op => { - if (op === "lowercase") return "rec['text'] = rec.get('text', '').lower()"; - if (op === "strip") return "rec['text'] = rec.get('text', '').strip()"; - if (op === "normalize_whitespace") return "import re; rec['text'] = re.sub(r'\\\\s+', ' ', rec.get('text', ''))"; - return `pass # Unknown op: ${op}`; - }).join("\n "); - return `python3 -c " -import json, os -os.makedirs(os.path.dirname('${outputPath}'), exist_ok=True) -count = 0 -with open('${inputPath}') as fin, open('${outputPath}', 'w') as fout: - for line in fin: - rec = json.loads(line) - ${opStr} - fout.write(json.dumps(rec) + '\\n') - count += 1 -print(f'Transform: processed={count}') -"`; -} - -function buildDedupCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string): string { - const fields = (step.config.fields as string[]) ?? ["text"]; - const method = (step.config.method as string) ?? "exact"; - return `python3 -c " -import json, hashlib, os -os.makedirs(os.path.dirname('${outputPath}'), exist_ok=True) -seen = set() -kept, dupes = 0, 0 -with open('${inputPath}') as fin, open('${outputPath}', 'w') as fout: - for line in fin: - rec = json.loads(line) - key_parts = [str(rec.get(f, '')) for f in ${JSON.stringify(fields)}] - key = hashlib.md5('|'.join(key_parts).encode()).hexdigest() - if key not in seen: - seen.add(key) - fout.write(line) - kept += 1 - else: - dupes += 1 -print(f'Dedup (${method}): kept={kept}, dupes={dupes}') -"`; -} - -function buildSplitCommand(step: PreprocessingStepSpec, inputPath: string, outputDir: string): string { - const trainRatio = (step.config.trainRatio as number) ?? 0.9; - const valRatio = (step.config.valRatio as number) ?? 0.05; - const seed = (step.config.seed as number) ?? 42; - return `python3 -c " -import json, random, os -os.makedirs('${outputDir}', exist_ok=True) -random.seed(${seed}) -lines = open('${inputPath}').readlines() -random.shuffle(lines) -n = len(lines) -train_end = int(n * ${trainRatio}) -val_end = train_end + int(n * ${valRatio}) -splits = {'train': lines[:train_end], 'val': lines[train_end:val_end], 'test': lines[val_end:]} -for name, data in splits.items(): - with open(f'${outputDir}/{name}.jsonl', 'w') as f: - f.writelines(data) - print(f'{name}: {len(data)} records') -print(f'Total: {n} records') -"`; -} - -function buildSampleCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string): string { - const n = (step.config.n as number) ?? 1000; - const seed = (step.config.seed as number) ?? 42; - return `python3 -c " -import random, os -os.makedirs(os.path.dirname('${outputPath}'), exist_ok=True) -random.seed(${seed}) -lines = open('${inputPath}').readlines() -sample = random.sample(lines, min(${n}, len(lines))) -with open('${outputPath}', 'w') as f: - f.writelines(sample) -print(f'Sampled {len(sample)} from {len(lines)}') -"`; -} - -function buildTokenizeCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string): string { - const tokenizer = (step.config.tokenizer as string) ?? "gpt2"; - return `python3 -c " -from transformers import AutoTokenizer -import json, os -os.makedirs(os.path.dirname('${outputPath}'), exist_ok=True) -tok = AutoTokenizer.from_pretrained('${tokenizer}') -count = 0 -with open('${inputPath}') as fin, open('${outputPath}', 'w') as fout: - for line in fin: - rec = json.loads(line) - tokens = tok.encode(rec.get('text', '')) - rec['token_ids'] = tokens - rec['token_count'] = len(tokens) - fout.write(json.dumps(rec) + '\\n') - count += 1 -print(f'Tokenized: {count} records with {tokenizer}') -"`; -} - -function buildValidateCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string): string { - const requiredFields = (step.config.requiredFields as string[]) ?? []; - return `python3 -c " -import json, os, sys -os.makedirs(os.path.dirname('${outputPath}'), exist_ok=True) -required = ${JSON.stringify(requiredFields)} -valid, invalid = 0, 0 -with open('${inputPath}') as fin, open('${outputPath}', 'w') as fout: - for i, line in enumerate(fin): - rec = json.loads(line) - if all(f in rec for f in required): - fout.write(line) - valid += 1 - else: - invalid += 1 -print(f'Validate: valid={valid}, invalid={invalid}') -if invalid > valid: - print('WARNING: more invalid than valid records', file=sys.stderr) -"`; -} - -function buildCustomCommand(step: PreprocessingStepSpec, inputPath: string, outputPath: string): string { - const script = (step.config.script as string) ?? "echo 'No script specified'"; - return `INPUT_PATH="${inputPath}" OUTPUT_PATH="${outputPath}" bash -c '${script}'`; -} - -// ------------------------------------------------------------------- -// Pipeline execution -// ------------------------------------------------------------------- - -/** - * Execute a full preprocessing pipeline for an experiment. - */ -export async function executePreprocessingPipeline( - spec: ExperimentSpec, - config: ExecutionPipelineConfig, -): Promise<PreprocessingRunResult> { - const pipeline = spec.preprocessing; - const steps: PreprocessingStepResult[] = []; - const startTime = Date.now(); - - if (!pipeline.enabled || pipeline.steps.length === 0) { - return { - experimentId: spec.experimentId, - pipelineName: spec.name, - steps: [], - overallStatus: "skipped", - totalDurationMs: 0, - outputPath: pipeline.outputPath, - }; - } - - // Sort steps by order - const sortedSteps = [...pipeline.steps].sort((a, b) => a.order - b.order); - - // Determine the input path chain: first step reads from data source, subsequent steps read from previous output - let currentInputPath = spec.dataSources[0]?.cachePath ?? pipeline.outputPath; - - for (const step of sortedSteps) { - const stepOutputPath = step.outputPath ?? `${pipeline.outputPath}/step_${step.order}_${step.name}`; - const configHash = hashStepConfig(step); - const hashFile = `${stepOutputPath}/.config_hash`; - - // Check if step can be skipped - if (config.skipExistingPreprocessing && pipeline.skipIfCached) { - const existingHash = readHash(hashFile); - if (existingHash === configHash && fileExists(stepOutputPath)) { - steps.push({ - stepName: step.name, - order: step.order, - status: "skipped", - inputPath: currentInputPath, - outputPath: stepOutputPath, - skippedReason: "Output exists and config unchanged", - configHash, - }); - currentInputPath = stepOutputPath; - continue; - } - } - - // Execute step - const stepStart = Date.now(); - const command = buildStepCommand(step, currentInputPath, stepOutputPath, pipeline.outputFormat); - - try { - const { stdout, exitCode } = await runCommand(command, { timeout: 3600_000 }); - - if (exitCode !== 0) { - steps.push({ - stepName: step.name, - order: step.order, - status: "failed", - inputPath: currentInputPath, - outputPath: stepOutputPath, - error: `Step failed (exit ${exitCode}): ${stdout.slice(0, 500)}`, - durationMs: Date.now() - stepStart, - configHash, - }); - - // Pipeline stops on failure - return { - experimentId: spec.experimentId, - pipelineName: spec.name, - steps, - overallStatus: "failed", - totalDurationMs: Date.now() - startTime, - outputPath: pipeline.outputPath, - }; - } - - // Parse record counts from stdout if available - const recordsMatch = stdout.match(/(\d+)\s+records?/i); - const recordsOut = recordsMatch ? parseInt(recordsMatch[1]) : undefined; - - steps.push({ - stepName: step.name, - order: step.order, - status: "completed", - inputPath: currentInputPath, - outputPath: stepOutputPath, - recordsOut, - durationMs: Date.now() - stepStart, - configHash, - }); - - currentInputPath = stepOutputPath; - } catch (error) { - steps.push({ - stepName: step.name, - order: step.order, - status: "failed", - inputPath: currentInputPath, - outputPath: stepOutputPath, - error: error instanceof Error ? error.message : "Step execution failed", - durationMs: Date.now() - stepStart, - configHash, - }); - - return { - experimentId: spec.experimentId, - pipelineName: spec.name, - steps, - overallStatus: "failed", - totalDurationMs: Date.now() - startTime, - outputPath: pipeline.outputPath, - }; - } - } - - return { - experimentId: spec.experimentId, - pipelineName: spec.name, - steps, - overallStatus: steps.every(s => s.status === "completed" || s.status === "skipped") ? "completed" : "failed", - totalDurationMs: Date.now() - startTime, - outputPath: pipeline.outputPath, - }; -} - -/** - * Generate a preprocessing manifest (for reproducibility). - */ -export function generatePreprocessingManifest( - result: PreprocessingRunResult, - pipeline: PreprocessingPipelineSpec, -): Record<string, unknown> { - return { - experimentId: result.experimentId, - pipelineName: result.pipelineName, - pipelineConfigHash: hashPipelineConfig(pipeline), - overallStatus: result.overallStatus, - totalDurationMs: result.totalDurationMs, - outputPath: result.outputPath, - steps: result.steps.map(s => ({ - name: s.stepName, - order: s.order, - status: s.status, - inputPath: s.inputPath, - outputPath: s.outputPath, - recordsOut: s.recordsOut, - durationMs: s.durationMs, - configHash: s.configHash, - skippedReason: s.skippedReason, - error: s.error, - })), - createdAt: new Date().toISOString(), - }; -} diff --git a/src/lib/deep-research/exec-readiness.ts b/src/lib/deep-research/exec-readiness.ts deleted file mode 100644 index 1c415d92..00000000 --- a/src/lib/deep-research/exec-readiness.ts +++ /dev/null @@ -1,272 +0,0 @@ -// ============================================================= -// Execution Pipeline — Readiness Checker -// ============================================================= -// Determines whether a research plan is ready for execution. -// Used by the Main Brain to reason about what's missing. - -import type { - ExperimentSpec, - ExperimentResources, - ExecutionPipelineConfig, - DryRunResult, -} from "./types"; -import { renderJobSpec } from "./exec-job-submitter"; - -// ------------------------------------------------------------------- -// Readiness check -// ------------------------------------------------------------------- - -export interface ReadinessReport { - ready: boolean; - blockers: ReadinessBlocker[]; - warnings: string[]; - /** Whether a dry-run spec can be generated now. */ - canDryRun: boolean; - /** Whether preprocessing needs to run first. */ - needsPreprocessing: boolean; - /** Whether datasets need to be fetched first. */ - needsDataFetch: boolean; - /** Scale classification. */ - scale: string; - /** Summary of required resources. */ - resourceSummary: string; - /** Summary of required datasets. */ - datasetSummary: string; - /** Commands that will be executed. */ - commandSummary: string[]; -} - -export interface ReadinessBlocker { - field: string; - issue: string; - suggestion: string; -} - -/** - * Check whether an ExperimentSpec is ready for execution. - */ -export function checkExecutionReadiness( - spec: ExperimentSpec, - _config: ExecutionPipelineConfig, -): ReadinessReport { - const blockers: ReadinessBlocker[] = []; - const warnings: string[] = []; - - // Check experiment basics - if (!spec.experimentId) { - blockers.push({ field: "experimentId", issue: "Missing experiment ID", suggestion: "Generate a unique experiment ID" }); - } - if (!spec.name) { - blockers.push({ field: "name", issue: "Missing experiment name", suggestion: "Provide a descriptive name" }); - } - if (spec.commands.length === 0) { - blockers.push({ field: "commands", issue: "No commands specified", suggestion: "Add train/eval commands" }); - } - - // Check data sources - for (const ds of spec.dataSources) { - if (!ds.identifier) { - blockers.push({ field: `dataSource.${ds.id}`, issue: `Data source ${ds.id} has no identifier`, suggestion: "Specify dataset identifier" }); - } - if (!ds.cachePath) { - blockers.push({ field: `dataSource.${ds.id}.cachePath`, issue: `No cache path for ${ds.id}`, suggestion: "Set cache path in config" }); - } - } - - // Check resources - if (spec.resources.gpu <= 0 && spec.taskType !== "eval_only" && spec.taskType !== "data_only") { - warnings.push("No GPU requested — this may be intentional for CPU-only tasks"); - } - if (spec.resources.memoryMb < 16000) { - warnings.push(`Low memory (${spec.resources.memoryMb} MB) — may cause OOM`); - } - - // Check preprocessing - const needsPreprocessing = spec.preprocessing.enabled && spec.preprocessing.steps.length > 0; - if (needsPreprocessing) { - for (const step of spec.preprocessing.steps) { - if (!step.name) { - blockers.push({ field: `preprocessing.step.${step.order}`, issue: "Step has no name", suggestion: "Name each preprocessing step" }); - } - } - if (!spec.preprocessing.outputPath) { - blockers.push({ field: "preprocessing.outputPath", issue: "No preprocessing output path", suggestion: "Set preprocessing output path" }); - } - } - - // Check environment - if (!spec.environment.workingDir) { - warnings.push("No working directory set — using default"); - } - - // Check outputs - if (!spec.outputs.baseDir) { - blockers.push({ field: "outputs.baseDir", issue: "No output base directory", suggestion: "Set experiment output directory" }); - } - - // Check commands have valid stages - for (const cmd of spec.commands) { - if (!["setup", "train", "eval", "postprocess"].includes(cmd.stage)) { - warnings.push(`Command "${cmd.name}" has unknown stage "${cmd.stage}"`); - } - } - - // Build summaries - const needsDataFetch = spec.dataSources.some(ds => ds.source !== "local"); - const canDryRun = blockers.length === 0; - - const resourceSummary = [ - `${spec.resources.gpu} GPU${spec.resources.gpuType ? ` (${spec.resources.gpuType})` : ""}`, - `${spec.resources.cpu} CPU`, - `${spec.resources.memoryMb} MB RAM`, - `${spec.resources.walltime} walltime`, - ].join(", "); - - const datasetSummary = spec.dataSources.length === 0 - ? "No datasets" - : spec.dataSources.map(ds => `${ds.name} (${ds.source}: ${ds.identifier})`).join(", "); - - const commandSummary = spec.commands.map(c => `[${c.stage}] ${c.command} ${c.args.join(" ")}`); - - return { - ready: blockers.length === 0, - blockers, - warnings, - canDryRun, - needsPreprocessing, - needsDataFetch, - scale: spec.scale, - resourceSummary, - datasetSummary, - commandSummary, - }; -} - -// ------------------------------------------------------------------- -// Dry-run generator -// ------------------------------------------------------------------- - -/** - * Generate a dry-run result showing exactly what would happen without actually doing it. - */ -export function generateDryRun( - spec: ExperimentSpec, - config: ExecutionPipelineConfig, -): DryRunResult { - const readiness = checkExecutionReadiness(spec, config); - - const renderedCommands = spec.commands.map(c => `${c.command} ${c.args.join(" ")}`); - - return { - experimentId: spec.experimentId, - mode: "dry_run", - renderedJobSpec: renderJobSpec(spec), - renderedCommands, - estimatedResources: spec.resources, - dataRequirements: spec.dataSources, - preprocessingSteps: spec.preprocessing.steps, - warnings: readiness.warnings, - blockers: readiness.blockers.map(b => `${b.field}: ${b.issue}`), - readyToSubmit: readiness.ready, - }; -} - -// ------------------------------------------------------------------- -// Resource estimation helpers -// ------------------------------------------------------------------- - -/** - * Estimate GPU hours based on dataset size and task type. - */ -export function estimateGPUHours( - datasetSizeGb: number, - taskType: string, - scale: string, -): number { - const scaleMultiplier = scale === "pilot" ? 0.1 : scale === "full" ? 1.0 : 0.5; - - const baseHours: Record<string, number> = { - training: datasetSizeGb * 10, - fine_tuning: datasetSizeGb * 5, - evaluation: datasetSizeGb * 1, - preprocessing: datasetSizeGb * 0.5, - }; - - const base = baseHours[taskType] ?? datasetSizeGb * 5; - return Math.max(1, Math.ceil(base * scaleMultiplier)); -} - -/** - * Suggest resources based on experiment parameters. - */ -export function suggestResources( - datasetSizeGb: number, - modelParams: string, - scale: string, -): Partial<ExperimentResources> { - // Parse model size (e.g., "7B" → 7) - const paramMatch = modelParams.match(/([\d.]+)\s*[BbMm]/); - const paramBillions = paramMatch ? parseFloat(paramMatch[1]) : 1; - - const gpuCount = paramBillions >= 13 ? 8 : paramBillions >= 7 ? 4 : paramBillions >= 3 ? 2 : 1; - const memoryMb = Math.max(64_000, Math.ceil(paramBillions * 20_000)); - - const hours = estimateGPUHours(datasetSizeGb, "training", scale); - const walltime = `${Math.min(72, hours)}:00:00`; - - return { - gpu: scale === "pilot" ? Math.min(gpuCount, 2) : gpuCount, - memoryMb: scale === "pilot" ? Math.min(memoryMb, 128_000) : memoryMb, - cpu: Math.max(8, gpuCount * 8), - walltime, - privateMachine: gpuCount >= 4 ? "yes" : "no", - }; -} - -// ------------------------------------------------------------------- -// Pilot / full-scale templates -// ------------------------------------------------------------------- - -/** - * Create a pilot-scale version of an experiment spec. - */ -export function toPilotSpec(spec: ExperimentSpec): ExperimentSpec { - return { - ...spec, - scale: "pilot", - name: `[PILOT] ${spec.name}`, - resources: { - ...spec.resources, - gpu: Math.min(spec.resources.gpu, 2), - memoryMb: Math.min(spec.resources.memoryMb, 128_000), - walltime: "4:00:00", - }, - commands: spec.commands.map(cmd => ({ - ...cmd, - args: [...cmd.args, "--max_steps=100", "--eval_steps=50"], - })), - retryPolicy: { ...spec.retryPolicy, maxRetries: 0 }, - }; -} - -/** - * Create a full-scale version from a pilot spec. - */ -export function toFullSpec( - pilotSpec: ExperimentSpec, - fullResources: Partial<ExperimentResources>, -): ExperimentSpec { - return { - ...pilotSpec, - scale: "full", - name: pilotSpec.name.replace("[PILOT] ", ""), - resources: { - ...pilotSpec.resources, - ...fullResources, - }, - commands: pilotSpec.commands.map(cmd => ({ - ...cmd, - args: cmd.args.filter(a => !a.startsWith("--max_steps=") && !a.startsWith("--eval_steps=")), - })), - }; -} diff --git a/src/lib/deep-research/execution-adapters.ts b/src/lib/deep-research/execution-adapters.ts deleted file mode 100644 index 0ab0fc22..00000000 --- a/src/lib/deep-research/execution-adapters.ts +++ /dev/null @@ -1,263 +0,0 @@ -// ============================================================= -// Deep Research — Execution Adapters -// ============================================================= -// Structured builders for rlaunch / rjob execution manifests. -// These are NOT raw string-only — every field is typed and configurable. - -import type { - ExecutionManifest, - RLaunchManifest, - RJobManifest, - MountSpec, - LauncherType, - ExecutionConfig, - ValidationStep, -} from "./types"; -import { DEFAULT_EXECUTION_CONFIG } from "./types"; - -// ------------------------------------------------------------------- -// Manifest Builders -// ------------------------------------------------------------------- - -export interface RLaunchOptions { - gpu?: number; - memoryMb?: number; - cpu?: number; - chargedGroup?: string; - privateMachine?: "yes" | "no" | "group"; - mounts?: MountSpec[]; - maxWaitDuration?: string; - command: string; - purpose: string; -} - -/** - * Build a structured rlaunch manifest from options + defaults. - * Template pattern: - * rlaunch --gpu=2 --memory=400000 --cpu=32 --charged-group=ai4sdata_gpu - * --private-machine=yes --mount=... --max-wait-duration=60m0s -- bash - */ -export function buildRLaunchManifest( - options: RLaunchOptions, - config: ExecutionConfig = DEFAULT_EXECUTION_CONFIG, -): RLaunchManifest { - return { - launcherType: "rlaunch", - gpu: options.gpu ?? config.defaultResources.gpu, - memoryMb: options.memoryMb ?? config.defaultResources.memoryMb, - cpu: options.cpu ?? config.defaultResources.cpu, - chargedGroup: options.chargedGroup ?? config.defaultChargedGroup, - privateMachine: options.privateMachine ?? config.defaultResources.privateMachine, - mounts: options.mounts ?? config.defaultMounts, - maxWaitDuration: options.maxWaitDuration ?? config.defaultResources.maxWaitDuration ?? "60m0s", - command: options.command, - purpose: options.purpose, - }; -} - -export interface RJobOptions { - jobName: string; - gpu?: number; - memoryMb?: number; - cpu?: number; - chargedGroup?: string; - privateMachine?: "yes" | "no" | "group"; - mounts?: MountSpec[]; - image: string; - command: string; - commandArgs?: string[]; - env?: Record<string, string>; - priority?: number; - hostNetwork?: boolean; - purpose: string; -} - -/** - * Build a structured rjob submission manifest from options + defaults. - * Template pattern: - * rjob submit --name=train-go-bp-3ep --gpu=4 --memory=200000 --cpu=32 - * --mount=... --image=... --charged-group=ai4sdata_gpu - * --private-machine=group -- bash -exc "..." - */ -export function buildRJobManifest( - options: RJobOptions, - config: ExecutionConfig = DEFAULT_EXECUTION_CONFIG, -): RJobManifest { - return { - launcherType: "rjob", - jobName: options.jobName, - gpu: options.gpu ?? config.defaultResources.gpu, - memoryMb: options.memoryMb ?? config.defaultResources.memoryMb, - cpu: options.cpu ?? config.defaultResources.cpu, - chargedGroup: options.chargedGroup ?? config.defaultChargedGroup, - privateMachine: options.privateMachine ?? config.defaultResources.privateMachine, - mounts: options.mounts ?? config.defaultMounts, - image: options.image, - command: options.command, - commandArgs: options.commandArgs ?? [], - env: options.env, - priority: options.priority, - hostNetwork: options.hostNetwork, - purpose: options.purpose, - }; -} - -// ------------------------------------------------------------------- -// Command Serialization (for display and submission) -// ------------------------------------------------------------------- - -/** - * Convert an rlaunch manifest to a sanitized, human-readable command string. - */ -export function rlaunchToCommand(manifest: RLaunchManifest): string { - const parts = ["rlaunch"]; - parts.push(`--gpu=${manifest.gpu}`); - parts.push(`--memory=${manifest.memoryMb}`); - parts.push(`--cpu=${manifest.cpu}`); - parts.push(`--charged-group=${manifest.chargedGroup}`); - parts.push(`--private-machine=${manifest.privateMachine}`); - for (const mount of manifest.mounts) { - parts.push(`--mount=${mount.source}:${mount.target}`); - } - if (manifest.maxWaitDuration) { - parts.push(`--max-wait-duration=${manifest.maxWaitDuration}`); - } - parts.push("--"); - parts.push(manifest.command); - return parts.join(" \\\n "); -} - -/** - * Convert an rjob manifest to a sanitized, human-readable command string. - */ -export function rjobToCommand(manifest: RJobManifest): string { - const parts = ["rjob submit"]; - parts.push(`--name=${manifest.jobName}`); - parts.push(`--gpu=${manifest.gpu}`); - parts.push(`--memory=${manifest.memoryMb}`); - parts.push(`--cpu=${manifest.cpu}`); - for (const mount of manifest.mounts) { - parts.push(`--mount=${mount.source}:${mount.target}`); - } - parts.push(`--image=${manifest.image}`); - parts.push(`--charged-group=${manifest.chargedGroup}`); - if (manifest.privateMachine) { - parts.push(`--private-machine=${manifest.privateMachine}`); - } - if (manifest.priority !== undefined) { - parts.push(`--priority=${manifest.priority}`); - } - if (manifest.hostNetwork) { - parts.push("--host-network"); - } - if (manifest.env) { - for (const [key, value] of Object.entries(manifest.env)) { - parts.push(`--env=${key}=${value}`); - } - } - parts.push("--"); - parts.push(manifest.command); - if (manifest.commandArgs.length > 0) { - parts.push(manifest.commandArgs.join(" ")); - } - return parts.join(" \\\n "); -} - -/** - * Convert any execution manifest to a sanitized command string. - */ -export function manifestToCommand(manifest: ExecutionManifest): string { - switch (manifest.launcherType) { - case "rlaunch": - return rlaunchToCommand(manifest); - case "rjob": - return rjobToCommand(manifest); - case "slurm": { - // Dynamic import not possible here; use registry fallback - const slurmAdapter = getLauncher("slurm" as unknown as LauncherType); - if (slurmAdapter) return slurmAdapter.toCommand(manifest); - return `[Slurm manifest — register slurm-launcher to serialize]`; - } - default: - return `[Unknown launcher type: ${(manifest as { launcherType: string }).launcherType}]`; - } -} - -// ------------------------------------------------------------------- -// Validation Step → Execution Manifest -// ------------------------------------------------------------------- - -/** - * Convert a validation step into an execution manifest. - * The main brain or workers call this when preparing execution nodes. - */ -export function validationStepToManifest( - step: ValidationStep, - config: ExecutionConfig = DEFAULT_EXECUTION_CONFIG, -): ExecutionManifest | null { - if (!step.command && !step.scriptPath) return null; - - const command = step.command || `bash ${step.scriptPath}`; - const launcherType = step.launcherType ?? config.defaultLauncherType; - - switch (launcherType) { - case "rlaunch": - return buildRLaunchManifest({ - command, - purpose: step.description, - }, config); - case "rjob": - return buildRJobManifest({ - jobName: `dr-step-${step.stepNumber}`, - image: "registry.h.pjlab.org.cn/ailab-ai4sdata-ai4sdata_gpu/swift:ubuntu22.04-cuda12.9.1-py311-torch2.8.0-vllm0.11.0-modelscope1.32.0-swift3.11.3", - command: "bash", - commandArgs: ["-exc", command], - purpose: step.description, - }, config); - default: - return null; - } -} - -// ------------------------------------------------------------------- -// Launcher Type Registry (extensible) -// ------------------------------------------------------------------- - -export interface LauncherAdapter { - type: LauncherType; - label: string; - description: string; - buildManifest: (options: Record<string, unknown>, config: ExecutionConfig) => ExecutionManifest; - toCommand: (manifest: ExecutionManifest) => string; -} - -const launcherRegistry = new Map<LauncherType, LauncherAdapter>(); - -export function registerLauncher(adapter: LauncherAdapter): void { - launcherRegistry.set(adapter.type, adapter); -} - -export function getLauncher(type: LauncherType): LauncherAdapter | undefined { - return launcherRegistry.get(type); -} - -export function getAvailableLaunchers(): LauncherAdapter[] { - return Array.from(launcherRegistry.values()); -} - -// Register built-in launchers -registerLauncher({ - type: "rlaunch", - label: "rlaunch", - description: "Request a development machine with GPU resources", - buildManifest: (opts, config) => buildRLaunchManifest(opts as unknown as RLaunchOptions, config), - toCommand: (m) => rlaunchToCommand(m as RLaunchManifest), -}); - -registerLauncher({ - type: "rjob", - label: "rjob submit", - description: "Submit a training/experiment job to the cluster", - buildManifest: (opts, config) => buildRJobManifest(opts as unknown as RJobOptions, config), - toCommand: (m) => rjobToCommand(m as RJobManifest), -}); diff --git a/src/lib/deep-research/execution-planner.ts b/src/lib/deep-research/execution-planner.ts deleted file mode 100644 index bd2d9b27..00000000 --- a/src/lib/deep-research/execution-planner.ts +++ /dev/null @@ -1,857 +0,0 @@ -// ============================================================= -// Deep Research — Execution Planner -// ============================================================= -// Converts a ValidationPlan + ClaimMap into a structured ExecutionPlanFull -// with stages for data acquisition, preprocessing, training, monitoring, -// and artifact collection. - -import { generateText } from "ai"; -import { getModelForRole, checkBudget, trackUsage } from "./model-router"; -import * as store from "./event-store"; -import type { - DeepResearchSession, - DeepResearchArtifact, - ArtifactProvenance, - ExecutionPlanFull, - ExecutionStage, - DataRequirement, - ValidationPlan, - ClaimMap, - NodeCreationSpec, - NodeType, - WorkerFanoutPlan, - WorkerDecompositionStrategy, - ValidationCriteria, - AggregationRules, - RetryPolicy, - LauncherType, - ExperimentSpec, - ExperimentResources, - ExperimentCommand, - DataSourceSpec, - PreprocessingPipelineSpec, - OutputConfig, - EnvironmentSetup, -} from "./types"; - -// ------------------------------------------------------------------- -// Prompt builder -// ------------------------------------------------------------------- - -/** - * Build a prompt for the LLM to generate a structured ExecutionPlanFull. - */ -export function buildExecutionPlanPrompt( - session: DeepResearchSession, - validationPlan: ValidationPlan, - claimMap?: ClaimMap | null, - synthesisArtifacts?: DeepResearchArtifact[], -): string { - const claimSection = claimMap - ? `\n## Claim Map Summary\n- ${claimMap.claims.length} claims (${claimMap.claims.filter(c => c.strength === "strong").length} strong)\n- ${claimMap.contradictions.length} contradictions\n- ${claimMap.gaps.length} gaps\n\nKey claims:\n${claimMap.claims.slice(0, 10).map(c => `- [${c.strength}] ${c.text}`).join("\n")}` - : ""; - - const synthSection = synthesisArtifacts && synthesisArtifacts.length > 0 - ? `\n## Synthesis Artifacts\n${synthesisArtifacts.map(a => `- ${a.title}: ${JSON.stringify(a.content).slice(0, 500)}`).join("\n")}` - : ""; - - return `Convert the following validation plan into a detailed, multi-stage execution plan. - -## Research Goal -${session.title} - -## Validation Plan -${JSON.stringify(validationPlan, null, 2)} -${claimSection} -${synthSection} - -## Instructions -Create a structured execution plan with the following stage types: -1. **data_download**: Acquire datasets (HuggingFace, GitHub, URLs) -2. **preprocess**: Clean, normalize, split, dedup data -3. **execute**: Run training/evaluation experiments -4. **monitor**: Track running jobs -5. **result_collect**: Gather outputs, metrics, artifacts - -For each stage, specify: -- Dependencies (which prior stages must complete first) -- Data requirements (name, source URL/ID, format, estimated size) -- Shell commands to execute -- Expected outputs - -## Resource Context -- Default GPU: ${session.config.execution.defaultResources.gpu} -- Default Memory: ${session.config.execution.defaultResources.memoryMb}MB -- Default CPU: ${session.config.execution.defaultResources.cpu} -- Launcher: ${session.config.execution.defaultLauncherType} -- Mounts: ${session.config.execution.defaultMounts.map(m => `${m.source}:${m.target}`).join(", ")} - -## Output Format -Respond with valid JSON: -{ - "stages": [ - { - "stageNumber": 1, - "name": "Download dataset", - "description": "Download X dataset from HuggingFace", - "nodeType": "data_download", - "dependencies": [], - "estimatedGPUHours": 0, - "dataRequirements": [ - { "name": "dataset_name", "source": "huggingface://org/dataset", "format": "jsonl", "estimatedSizeGb": 5.0, "cachePath": "/mnt/data/dataset" } - ], - "commands": ["huggingface-cli download org/dataset --local-dir /mnt/data/dataset"], - "expectedOutputs": ["/mnt/data/dataset/train.jsonl"] - } - ], - "totalEstimatedGPUHours": 48, - "dataRequirements": [...all unique data requirements...], - "prerequisites": ["Python 3.10+", "CUDA 12.x", "PyTorch 2.x"] -} - -Be specific and executable. Every command should be runnable.`; -} - -// ------------------------------------------------------------------- -// Generate execution plan via LLM -// ------------------------------------------------------------------- - -/** - * Generate a structured ExecutionPlanFull by calling the LLM. - */ -export async function generateExecutionPlan( - session: DeepResearchSession, - validationPlan: ValidationPlan, - claimMap?: ClaimMap | null, - synthesisArtifacts?: DeepResearchArtifact[], - abortSignal?: AbortSignal, -): Promise<ExecutionPlanFull> { - const { model } = getModelForRole("main_brain", session.config); - const budgetCheck = checkBudget("main_brain", session.budget, session.config.budget); - - if (!budgetCheck.allowed) { - throw new Error(`Execution planner budget exceeded: ${budgetCheck.reason}`); - } - - // Create planner node - const plannerNode = await store.createNode(session.id, { - nodeType: "validation_plan", - label: "Generate execution plan", - assignedRole: "experiment_architecture_designer", - input: { - validationPlan, - hasClaimMap: !!claimMap, - hasSynthesis: !!(synthesisArtifacts && synthesisArtifacts.length > 0), - }, - contextTag: "planning", - }); - - await store.updateNode(plannerNode.id, { - status: "running", - startedAt: new Date().toISOString(), - }); - - try { - const prompt = buildExecutionPlanPrompt(session, validationPlan, claimMap, synthesisArtifacts); - - const result = await generateText({ - model, - system: "You are an ML execution planner. Convert a validation plan into a concrete, multi-stage execution plan. Respond ONLY with valid JSON.", - messages: [{ role: "user", content: prompt }], - abortSignal, - }); - - const tokens = result.usage?.totalTokens ?? 0; - const budget = trackUsage(session.budget, "main_brain", plannerNode.id, tokens); - await store.updateSession(session.id, { budget }); - - const plan = parseExecutionPlan(result.text); - - // Validate - const validation = validateExecutionPlan(plan); - if (!validation.valid) { - console.warn("[execution-planner] Plan validation warnings:", validation.errors); - } - - // Mark node completed - await store.updateNode(plannerNode.id, { - status: "completed", - output: plan as unknown as Record<string, unknown>, - completedAt: new Date().toISOString(), - }); - - // Save artifact - const provenance: ArtifactProvenance = { - sourceNodeId: plannerNode.id, - sourceArtifactIds: [], - model: "main_brain", - generatedAt: new Date().toISOString(), - }; - - await store.createArtifact( - session.id, - plannerNode.id, - "execution_plan", - `Execution Plan (${plan.stages.length} stages, ~${plan.totalEstimatedGPUHours} GPU-hours)`, - plan as unknown as Record<string, unknown>, - provenance, - ); - - await store.appendEvent(session.id, "execution_plan_created", plannerNode.id, "main_brain", undefined, undefined, { - stageCount: plan.stages.length, - totalGPUHours: plan.totalEstimatedGPUHours, - dataRequirements: plan.dataRequirements.length, - }); - - return plan; - } catch (error) { - const message = error instanceof Error ? error.message : "Execution plan generation failed"; - await store.updateNode(plannerNode.id, { - status: "failed", - error: message, - completedAt: new Date().toISOString(), - }); - throw error; - } -} - -// ------------------------------------------------------------------- -// Validation -// ------------------------------------------------------------------- - -/** - * Validate an ExecutionPlanFull for completeness and correctness. - */ -export function validateExecutionPlan(plan: ExecutionPlanFull): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!plan.stages || plan.stages.length === 0) { - errors.push("Execution plan has no stages"); - return { valid: false, errors }; - } - - const stageNumbers = new Set(plan.stages.map(s => s.stageNumber)); - - for (const stage of plan.stages) { - // Check commands - if (!stage.commands || stage.commands.length === 0) { - errors.push(`Stage ${stage.stageNumber} (${stage.name}) has no commands`); - } - - // Check dependencies reference valid stages - for (const dep of stage.dependencies) { - if (!stageNumbers.has(dep)) { - errors.push(`Stage ${stage.stageNumber} depends on non-existent stage ${dep}`); - } - if (dep >= stage.stageNumber) { - errors.push(`Stage ${stage.stageNumber} has forward dependency on stage ${dep}`); - } - } - - // Check data requirements have sources - for (const req of stage.dataRequirements) { - if (!req.source) { - errors.push(`Stage ${stage.stageNumber}: data requirement "${req.name}" has no source`); - } - } - } - - // Check for circular dependencies - const visited = new Set<number>(); - const visiting = new Set<number>(); - - function hasCycle(stageNum: number): boolean { - if (visiting.has(stageNum)) return true; - if (visited.has(stageNum)) return false; - - visiting.add(stageNum); - const stage = plan.stages.find(s => s.stageNumber === stageNum); - if (stage) { - for (const dep of stage.dependencies) { - if (hasCycle(dep)) return true; - } - } - visiting.delete(stageNum); - visited.add(stageNum); - return false; - } - - for (const stage of plan.stages) { - visited.clear(); - visiting.clear(); - if (hasCycle(stage.stageNumber)) { - errors.push(`Circular dependency detected involving stage ${stage.stageNumber}`); - break; - } - } - - return { valid: errors.length === 0, errors }; -} - -// ------------------------------------------------------------------- -// Convert plan to node specs -// ------------------------------------------------------------------- - -/** - * Convert an ExecutionPlanFull into NodeCreationSpec[] for orchestrator dispatch. - */ -export function executionPlanToNodeSpecs( - plan: ExecutionPlanFull, - _session: DeepResearchSession, -): NodeCreationSpec[] { - const specs: NodeCreationSpec[] = []; - - // Map stage numbers to node IDs (will be filled after creation) - const stageToContextTag: Record<string, "planning"> = { - data_download: "planning", - preprocess: "planning", - execute: "planning", - monitor: "planning", - result_collect: "planning", - }; - - for (const stage of plan.stages) { - const nodeType = mapStageNodeType(stage.nodeType); - const contextTag = stageToContextTag[stage.nodeType] ?? "planning"; - - specs.push({ - nodeType, - label: `Stage ${stage.stageNumber}: ${stage.name}`, - assignedRole: "experiment_operations_engineer", - input: { - stageNumber: stage.stageNumber, - description: stage.description, - commands: stage.commands, - expectedOutputs: stage.expectedOutputs, - dataRequirements: stage.dataRequirements, - estimatedGPUHours: stage.estimatedGPUHours, - }, - contextTag, - }); - } - - return specs; -} - -function mapStageNodeType(stageNodeType: NodeType): NodeType { - const valid: NodeType[] = [ - "data_download", "preprocess", "execute", "monitor", "result_collect", - "resource_request", "evidence_gather", - ]; - if (valid.includes(stageNodeType)) return stageNodeType; - return "execute"; -} - -// ------------------------------------------------------------------- -// Parsing -// ------------------------------------------------------------------- - -function parseExecutionPlan(text: string): ExecutionPlanFull { - const parsed = extractJsonFromText(text); - - if (!parsed) { - return { stages: [], totalEstimatedGPUHours: 0, dataRequirements: [], prerequisites: [] }; - } - - const stages: ExecutionStage[] = []; - if (Array.isArray(parsed.stages)) { - for (const s of parsed.stages) { - if (s && typeof s === "object") { - stages.push({ - stageNumber: typeof s.stageNumber === "number" ? s.stageNumber : stages.length + 1, - name: String(s.name ?? `Stage ${stages.length + 1}`), - description: String(s.description ?? ""), - nodeType: (s.nodeType as NodeType) ?? "execute", - dependencies: Array.isArray(s.dependencies) ? s.dependencies : [], - estimatedGPUHours: typeof s.estimatedGPUHours === "number" ? s.estimatedGPUHours : 0, - dataRequirements: Array.isArray(s.dataRequirements) ? s.dataRequirements.map(parseDataReq) : [], - commands: Array.isArray(s.commands) ? s.commands.map(String) : [], - expectedOutputs: Array.isArray(s.expectedOutputs) ? s.expectedOutputs.map(String) : [], - }); - } - } - } - - return { - stages, - totalEstimatedGPUHours: typeof parsed.totalEstimatedGPUHours === "number" ? parsed.totalEstimatedGPUHours : stages.reduce((s, st) => s + st.estimatedGPUHours, 0), - dataRequirements: Array.isArray(parsed.dataRequirements) ? parsed.dataRequirements.map(parseDataReq) : [], - prerequisites: Array.isArray(parsed.prerequisites) ? parsed.prerequisites.map(String) : [], - }; -} - -function parseDataReq(raw: unknown): DataRequirement { - if (!raw || typeof raw !== "object") { - return { name: "unknown", source: "", format: "jsonl", estimatedSizeGb: 0 }; - } - const r = raw as Record<string, unknown>; - return { - name: String(r.name ?? "unknown"), - source: String(r.source ?? ""), - format: String(r.format ?? "jsonl"), - estimatedSizeGb: typeof r.estimatedSizeGb === "number" ? r.estimatedSizeGb : 0, - cachePath: r.cachePath ? String(r.cachePath) : undefined, - }; -} - -function extractJsonFromText(text: string): Record<string, unknown> | null { - try { - const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); - if (fenceMatch) return JSON.parse(fenceMatch[1].trim()); - - const firstBrace = text.indexOf("{"); - if (firstBrace >= 0) { - let depth = 0; - let inString = false; - let escape = false; - for (let i = firstBrace; i < text.length; i++) { - const ch = text[i]; - if (escape) { escape = false; continue; } - if (ch === "\\") { escape = true; continue; } - if (ch === '"') { inString = !inString; continue; } - if (inString) continue; - if (ch === "{") depth++; - if (ch === "}") { - depth--; - if (depth === 0) return JSON.parse(text.slice(firstBrace, i + 1)); - } - } - } - return JSON.parse(text.trim()); - } catch { - return null; - } -} - -// ------------------------------------------------------------------- -// Worker Fanout Planning -// ------------------------------------------------------------------- - -/** - * Decide the best decomposition strategy for a given validation plan. - */ -export function inferDecompositionStrategy( - validationPlan: ValidationPlan, -): WorkerDecompositionStrategy { - const text = JSON.stringify(validationPlan).toLowerCase(); - - if (text.includes("ablation")) return "ablation"; - if (text.includes("hyperparameter") || text.includes("hyper-parameter") || text.includes("grid search") || text.includes("sweep")) { - return "hyperparameter_sweep"; - } - if (text.includes("seed") || text.includes("replicate") || text.includes("variance")) { - return "seed_sweep"; - } - if (text.includes("benchmark") || text.includes("shard")) return "benchmark_shard"; - if (text.includes("model variant") || text.includes("model comparison")) return "model_variant"; - if (text.includes("preprocess")) return "preprocessing_shard"; - if (text.includes("train") && text.includes("eval")) return "train_eval_split"; - return "seed_sweep"; -} - -/** - * Build a WorkerFanoutPlan from an ExecutionPlanFull. - * - * Converts a high-level plan into a concrete worker decomposition - * with parameter spaces, validation criteria, aggregation rules, - * and resource estimates. - */ -export function buildWorkerFanoutPlan( - plan: ExecutionPlanFull, - session: DeepResearchSession, - opts?: { - strategy?: WorkerDecompositionStrategy; - seeds?: number[]; - parameterSpace?: Array<{ name: string; values: unknown[] }>; - validationCriteria?: Partial<ValidationCriteria>; - launcherOverride?: LauncherType; - maxParallel?: number; - pilotFirst?: boolean; - }, -): WorkerFanoutPlan { - const strategy = opts?.strategy ?? "seed_sweep"; - - // Build parameter space - const parameterSpace: Array<{ name: string; values: unknown[] }> = opts?.parameterSpace ?? []; - if (strategy === "seed_sweep" && !parameterSpace.find(p => p.name === "seed")) { - parameterSpace.push({ name: "seed", values: opts?.seeds ?? [42, 123, 456] }); - } - - // Calculate total workers from parameter space - const totalWorkers = parameterSpace.length > 0 - ? parameterSpace.reduce((acc, p) => acc * p.values.length, 1) - : 1; - - // Build base experiment spec from the execute stages - const parentSpec = buildBaseExperimentSpec(plan, session, opts?.launcherOverride); - - // Build per-worker resources (same as parent by default) - const perWorkerResources: ExperimentResources = { ...parentSpec.resources }; - - // Estimate total GPU hours - const perWorkerHours = plan.totalEstimatedGPUHours / Math.max(plan.stages.filter(s => s.nodeType === "execute").length, 1); - const estimatedTotalGPUHours = perWorkerHours * totalWorkers; - - return { - parentSpec, - strategy, - parameterSpace, - totalWorkers, - maxParallel: opts?.maxParallel ?? 4, - pilotFirst: opts?.pilotFirst ?? (estimatedTotalGPUHours > 10), - dependencyType: "independent", - validationCriteria: buildValidationCriteria(strategy, opts?.validationCriteria), - aggregationRules: buildAggregationRules(strategy), - estimatedTotalGPUHours, - perWorkerResources, - }; -} - -/** - * Build a base ExperimentSpec from execution plan stages. - */ -function buildBaseExperimentSpec( - plan: ExecutionPlanFull, - session: DeepResearchSession, - launcherOverride?: LauncherType, -): ExperimentSpec { - const execConfig = session.config.execution; - const res = execConfig.defaultResources; - const now = new Date().toISOString(); - - const commands: ExperimentCommand[] = []; - for (const stage of plan.stages) { - const cmdStage = stage.nodeType === "data_download" ? "setup" - : stage.nodeType === "preprocess" ? "setup" - : stage.nodeType === "execute" ? "train" - : stage.nodeType === "result_collect" ? "eval" - : "setup"; - - for (const cmd of stage.commands) { - const parts = cmd.split(/\s+/); - commands.push({ - name: stage.name, - stage: cmdStage as "setup" | "train" | "eval" | "postprocess", - command: parts[0], - args: parts.slice(1), - dependsOn: stage.dependencies.map(d => { - const depStage = plan.stages.find(s => s.stageNumber === d); - return depStage?.name ?? `stage-${d}`; - }), - }); - } - } - - const dataSources: DataSourceSpec[] = plan.dataRequirements.map((req, i) => ({ - id: `ds-${i}`, - name: req.name, - source: (req.source.includes("://") ? req.source.split("://")[0] : "url") as "huggingface" | "github" | "url" | "local", - identifier: req.source.includes("://") ? req.source.split("://").slice(1).join("://") : req.source, - format: req.format, - estimatedSizeGb: req.estimatedSizeGb, - cachePath: req.cachePath ?? `/mnt/data/${req.name}`, - })); - - const resources: ExperimentResources = { - gpu: res.gpu, - cpu: res.cpu, - memoryMb: res.memoryMb, - walltime: res.maxWaitDuration ?? "24h", - privateMachine: res.privateMachine, - }; - - const environment: EnvironmentSetup = { - modules: [], - condaEnv: undefined, - venvPath: undefined, - setupCommands: [], - workingDir: "/workspace", - envVars: {}, - }; - - const outputs: OutputConfig = { - baseDir: "/output", - checkpointDir: "/output/checkpoints", - logDir: "/output/logs", - metricsDir: "/output/metrics", - artifactPatterns: ["*.json", "*.pt", "*.bin"], - }; - - const preprocessing: PreprocessingPipelineSpec = { - enabled: plan.stages.some(s => s.nodeType === "preprocess"), - steps: [], - outputPath: "/mnt/data/processed", - outputFormat: "jsonl", - skipIfCached: true, - }; - - const retryPolicy: RetryPolicy = { - maxRetries: 2, - retryOnOOM: true, - retryDelaySeconds: 60, - scaleDownOnOOM: true, - }; - - return { - experimentId: `spec-${Date.now()}`, - sessionId: session.id, - name: session.title.slice(0, 64), - description: `Execution plan: ${plan.stages.length} stages, ~${plan.totalEstimatedGPUHours} GPU-hours`, - scale: plan.totalEstimatedGPUHours <= 2 ? "pilot" : "full", - status: "planning", - taskType: "research", - models: [], - launcherType: launcherOverride ?? execConfig.defaultLauncherType, - submissionMode: "dry_run", - resources, - dataSources, - preprocessing, - commands, - environment, - mounts: execConfig.defaultMounts, - outputs, - retryPolicy, - createdAt: now, - updatedAt: now, - }; -} - -// ------------------------------------------------------------------- -// Validation Criteria Builder -// ------------------------------------------------------------------- - -/** - * Build ValidationCriteria with sensible defaults for a given strategy. - */ -export function buildValidationCriteria( - strategy: WorkerDecompositionStrategy, - overrides?: Partial<ValidationCriteria>, -): ValidationCriteria { - const base: ValidationCriteria = { - metricThresholds: [], - requiredArtifacts: ["metrics.json"], - minSuccessfulWorkers: 1, - maxVariance: strategy === "seed_sweep" ? 0.3 : null, - baselineRequired: false, - baselineMetrics: {}, - customConditions: [], - }; - - // Strategy-specific defaults - switch (strategy) { - case "seed_sweep": - base.minSuccessfulWorkers = 2; - base.maxVariance = 0.3; - break; - case "hyperparameter_sweep": - base.minSuccessfulWorkers = 1; // at least one config must work - break; - case "ablation": - base.minSuccessfulWorkers = 2; - base.requiredArtifacts.push("ablation_results.json"); - break; - case "benchmark_shard": - base.minSuccessfulWorkers = 1; - break; - case "model_variant": - base.minSuccessfulWorkers = 2; - base.baselineRequired = true; - break; - } - - // Apply overrides - if (overrides) { - if (overrides.metricThresholds) base.metricThresholds = overrides.metricThresholds; - if (overrides.requiredArtifacts) base.requiredArtifacts = overrides.requiredArtifacts; - if (overrides.minSuccessfulWorkers !== undefined) base.minSuccessfulWorkers = overrides.minSuccessfulWorkers; - if (overrides.maxVariance !== undefined) base.maxVariance = overrides.maxVariance; - if (overrides.baselineRequired !== undefined) base.baselineRequired = overrides.baselineRequired; - if (overrides.baselineMetrics) base.baselineMetrics = overrides.baselineMetrics; - if (overrides.customConditions) base.customConditions = overrides.customConditions; - } - - return base; -} - -// ------------------------------------------------------------------- -// Aggregation Rules Builder -// ------------------------------------------------------------------- - -/** - * Build AggregationRules appropriate for a given decomposition strategy. - */ -export function buildAggregationRules( - strategy: WorkerDecompositionStrategy, -): AggregationRules { - const base: AggregationRules = { - metricAggregation: "mean", - minSuccessRate: 0.8, - metricsToAggregate: ["accuracy", "loss", "f1"], - computeVariance: false, - maxCoefficientOfVariation: null, - customAggregator: null, - }; - - switch (strategy) { - case "seed_sweep": - base.metricAggregation = "mean"; - base.computeVariance = true; - base.maxCoefficientOfVariation = 0.3; - base.minSuccessRate = 0.67; - break; - case "hyperparameter_sweep": - base.metricAggregation = "max"; - base.minSuccessRate = 0.5; - break; - case "ablation": - base.metricAggregation = "all"; - base.minSuccessRate = 0.9; - break; - case "benchmark_shard": - base.metricAggregation = "mean"; - base.minSuccessRate = 0.8; - break; - case "model_variant": - base.metricAggregation = "all"; - base.minSuccessRate = 1.0; - break; - } - - return base; -} - -// ------------------------------------------------------------------- -// Retry Policy Builder -// ------------------------------------------------------------------- - -/** - * Build a RetryPolicy with sensible defaults. - */ -export function buildRetryPolicy(overrides?: Partial<RetryPolicy>): RetryPolicy { - return { - maxRetries: overrides?.maxRetries ?? 2, - retryOnOOM: overrides?.retryOnOOM ?? true, - retryDelaySeconds: overrides?.retryDelaySeconds ?? 60, - scaleDownOnOOM: overrides?.scaleDownOnOOM ?? true, - }; -} - -// ------------------------------------------------------------------- -// Backend preference selection -// ------------------------------------------------------------------- - -/** - * Select the best launcher type based on experiment requirements. - */ -export function selectLauncherType( - resources: ExperimentResources, - session: DeepResearchSession, -): LauncherType { - const configLauncher = session.config.execution.defaultLauncherType; - - // If user has a preference, respect it - if (configLauncher !== "rjob") return configLauncher; - - // Heuristic: use rlaunch for interactive / short experiments - if (resources.gpu <= 1 && parseWalltime(resources.walltime) <= 2) { - return "rlaunch"; - } - - // Large multi-GPU → rjob for proper scheduling - if (resources.gpu >= 4) return "rjob"; - - return configLauncher; -} - -function parseWalltime(walltime: string): number { - const match = walltime.match(/^(\d+)h/); - return match ? parseInt(match[1], 10) : 24; -} - -// ------------------------------------------------------------------- -// Experiment DAG builder -// ------------------------------------------------------------------- - -/** - * Build multiple WorkerFanoutPlans from a complex validation plan - * that may require staged experiments (e.g., pilot → full run). - */ -export function buildExperimentDAG( - plan: ExecutionPlanFull, - session: DeepResearchSession, - validationPlan: ValidationPlan, -): WorkerFanoutPlan[] { - const strategy = inferDecompositionStrategy(validationPlan); - const stages: WorkerFanoutPlan[] = []; - - // If plan has many GPU hours, split into pilot + full - if (plan.totalEstimatedGPUHours > 10) { - const pilotPlan = buildWorkerFanoutPlan(plan, session, { - strategy: "seed_sweep", - parameterSpace: [{ name: "seed", values: [42] }], - pilotFirst: false, - maxParallel: 1, - validationCriteria: { - minSuccessfulWorkers: 1, - metricThresholds: [], // pilot just needs to complete - }, - }); - pilotPlan.parentSpec.name = `[Pilot] ${pilotPlan.parentSpec.name}`; - pilotPlan.parentSpec.scale = "pilot"; - stages.push(pilotPlan); - } - - // Full run with the inferred strategy - stages.push(buildWorkerFanoutPlan(plan, session, { strategy })); - - return stages; -} - -// ------------------------------------------------------------------- -// Prompt builder for worker fanout (LLM-assisted) -// ------------------------------------------------------------------- - -/** - * Build a prompt for the LLM to generate worker fanout parameters. - * Used when the parameter space is too complex to infer heuristically. - */ -export function buildWorkerFanoutPrompt( - plan: ExecutionPlanFull, - session: DeepResearchSession, - validationPlan: ValidationPlan, -): string { - return `Given the following execution plan and validation requirements, design a worker fanout plan. - -## Research Goal -${session.title} - -## Execution Plan Summary -- Stages: ${plan.stages.length} -- Total GPU-hours: ${plan.totalEstimatedGPUHours} -- Execute stages: ${plan.stages.filter(s => s.nodeType === "execute").map(s => s.name).join(", ")} - -## Validation Plan -${JSON.stringify(validationPlan, null, 2)} - -## Available Decomposition Strategies -- seed_sweep: Same config, multiple random seeds (for variance estimation) -- hyperparameter_sweep: Grid/random search over hyperparameters -- ablation: Remove one component at a time to measure impact -- benchmark_shard: Split benchmark across shards -- model_variant: Compare different model configurations -- replay_budget: Vary replay budget for RL -- preprocessing_shard: Shard preprocessing -- train_eval_split: Separate train and eval workers -- custom: Custom decomposition - -## Instructions -Choose the best strategy and specify: -1. **strategy**: One of the above -2. **parameterSpace**: Array of {name, values} (e.g., [{name: "seed", values: [42, 123, 456]}]) -3. **dependencyType**: "independent", "sequential", or "staged_dag" -4. **maxParallel**: How many workers to run at once -5. **validationCriteria**: Metric thresholds, required artifacts, min successful workers -6. **aggregationRules**: How to combine results - -Respond with valid JSON matching the WorkerFanoutPlan schema.`; -} diff --git a/src/lib/deep-research/execution-round-manager.ts b/src/lib/deep-research/execution-round-manager.ts deleted file mode 100644 index 59482626..00000000 --- a/src/lib/deep-research/execution-round-manager.ts +++ /dev/null @@ -1,565 +0,0 @@ -// ============================================================= -// Execution Round Manager — Iterative plan→execute→validate→replan -// ============================================================= -// Manages the full lifecycle of execution rounds: -// 1. Convert approved plan → worker specs -// 2. Submit workers (parallel or staged) -// 3. Monitor completion -// 4. Aggregate results -// 5. Validate against criteria -// 6. Route failures to experiment analysis -// 7. Support Main Brain replanning -// 8. Track lineage across rounds - -import type { - ExperimentSpec, - ExperimentGroup, - WorkerRun, - WorkerFanoutPlan, - ExecutionRound, - ExecutionLineage, - ExperimentAnalysisResult, - ValidationPlan, - JobStatus, - SubmissionMode, - WorkerDecompositionStrategy, - WorkerRunStatus, -} from "./types"; -import type { SubmissionAdapter } from "./exec-job-submitter"; -import { aggregateWorkerResults, computeGroupStatus, createDefaultAggregationRules } from "./worker-aggregator"; -import { validateExperimentResults, createDefaultValidationCriteria } from "./execution-validator"; -import { analyzeExperimentFailure, shouldStopExecution } from "./experiment-analysis"; -import { nanoid } from "nanoid"; - -// ------------------------------------------------------------------- -// Worker Fanout — Decompose experiment into workers -// ------------------------------------------------------------------- - -/** - * Create worker runs from a fanout plan. - */ -export function createWorkerRuns(plan: WorkerFanoutPlan): WorkerRun[] { - const workers: WorkerRun[] = []; - const groupId = `grp-${nanoid(8)}`; - - if (plan.parameterSpace.length === 0) { - // Single worker (no decomposition) - workers.push(createSingleWorker(plan.parentSpec, groupId, plan.parentSpec.experimentId, {})); - return workers; - } - - // Generate parameter combinations - const combinations = generateParameterCombinations(plan.parameterSpace); - const effectiveCount = Math.min(combinations.length, plan.totalWorkers); - - for (let i = 0; i < effectiveCount; i++) { - const params = combinations[i]; - const label = Object.entries(params).map(([k, v]) => `${k}=${v}`).join(","); - const workerId = `w-${nanoid(6)}`; - - // Create worker-specific spec by cloning parent and applying overrides - const workerSpec: ExperimentSpec = { - ...plan.parentSpec, - experimentId: `${plan.parentSpec.experimentId}-${workerId}`, - name: `${plan.parentSpec.name} [${label}]`, - commands: applyParamOverrides(plan.parentSpec.commands, params), - resources: { ...plan.perWorkerResources }, - }; - - workers.push({ - workerId, - parentExperimentId: plan.parentSpec.experimentId, - groupId, - label, - spec: workerSpec, - jobId: null, - status: "pending", - paramOverrides: params, - metrics: {}, - artifactPaths: [], - logTail: "", - exitCode: null, - runtimeSec: null, - error: null, - submittedAt: null, - startedAt: null, - completedAt: null, - createdAt: new Date().toISOString(), - }); - } - - return workers; -} - -function createSingleWorker( - spec: ExperimentSpec, - groupId: string, - parentId: string, - params: Record<string, unknown>, -): WorkerRun { - return { - workerId: `w-${nanoid(6)}`, - parentExperimentId: parentId, - groupId, - label: "single", - spec, - jobId: null, - status: "pending", - paramOverrides: params, - metrics: {}, - artifactPaths: [], - logTail: "", - exitCode: null, - runtimeSec: null, - error: null, - submittedAt: null, - startedAt: null, - completedAt: null, - createdAt: new Date().toISOString(), - }; -} - -/** - * Generate all combinations from a parameter space. - */ -export function generateParameterCombinations( - parameterSpace: Array<{ name: string; values: unknown[] }>, -): Array<Record<string, unknown>> { - if (parameterSpace.length === 0) return [{}]; - - const result: Array<Record<string, unknown>> = []; - - function recurse(idx: number, current: Record<string, unknown>) { - if (idx >= parameterSpace.length) { - result.push({ ...current }); - return; - } - const param = parameterSpace[idx]; - for (const value of param.values) { - current[param.name] = value; - recurse(idx + 1, current); - } - } - - recurse(0, {}); - return result; -} - -function applyParamOverrides( - commands: ExperimentSpec["commands"], - params: Record<string, unknown>, -): ExperimentSpec["commands"] { - return commands.map(cmd => ({ - ...cmd, - args: [ - ...cmd.args, - ...Object.entries(params).map(([k, v]) => `--${k}=${v}`), - ], - })); -} - -// ------------------------------------------------------------------- -// Experiment Group Builder -// ------------------------------------------------------------------- - -/** - * Build an ExperimentGroup from a fanout plan. - */ -export function buildExperimentGroup( - sessionId: string, - roundNumber: number, - plan: WorkerFanoutPlan, -): ExperimentGroup { - const workers = createWorkerRuns(plan); - const groupId = workers[0]?.groupId ?? `grp-${nanoid(8)}`; - - // Build dependency graph - const dependencyGraph: Record<string, string[]> = {}; - if (plan.dependencyType === "sequential") { - for (let i = 1; i < workers.length; i++) { - dependencyGraph[workers[i].workerId] = [workers[i - 1].workerId]; - } - } - // independent and staged_dag leave deps empty (or custom-set later) - - // Update all workers with the correct groupId - for (const w of workers) { - (w as { groupId: string }).groupId = groupId; - } - - return { - groupId, - sessionId, - roundNumber, - parentSpec: plan.parentSpec, - decompositionStrategy: plan.strategy, - workers, - dependencyGraph, - aggregationRules: plan.aggregationRules, - validationCriteria: plan.validationCriteria, - status: "pending", - aggregatedResult: null, - createdAt: new Date().toISOString(), - completedAt: null, - }; -} - -// ------------------------------------------------------------------- -// Worker Submission — Submit all workers through an adapter -// ------------------------------------------------------------------- - -/** - * Submit all ready workers in a group. - * Respects dependency graph: only submit workers whose deps are completed. - */ -export async function submitGroupWorkers( - group: ExperimentGroup, - adapter: SubmissionAdapter, - mode: SubmissionMode, - maxParallel: number = Infinity, -): Promise<ExperimentGroup> { - const updated = { ...group, workers: [...group.workers] }; - let submitted = 0; - - for (let i = 0; i < updated.workers.length; i++) { - const worker = updated.workers[i]; - if (worker.status !== "pending") continue; - if (submitted >= maxParallel) break; - - // Check dependencies - const deps = updated.dependencyGraph[worker.workerId] ?? []; - const depsReady = deps.every(depId => { - const dep = updated.workers.find(w => w.workerId === depId); - return dep && dep.status === "completed"; - }); - - if (!depsReady && deps.length > 0) continue; - - // Submit - const result = await adapter.submit(worker.spec, mode); - updated.workers[i] = { - ...worker, - jobId: result.jobId, - status: result.success ? "queued" as WorkerRunStatus : "failed" as WorkerRunStatus, - error: result.success ? null : result.message, - submittedAt: new Date().toISOString(), - }; - submitted++; - } - - updated.status = "running"; - return updated; -} - -// ------------------------------------------------------------------- -// Worker Monitoring — Check status of all submitted workers -// ------------------------------------------------------------------- - -/** - * Poll status of all active workers. - */ -export async function pollWorkerStatuses( - group: ExperimentGroup, - adapter: SubmissionAdapter, -): Promise<ExperimentGroup> { - const updated = { ...group, workers: [...group.workers] }; - - for (let i = 0; i < updated.workers.length; i++) { - const worker = updated.workers[i]; - if (!worker.jobId) continue; - if (worker.status === "completed" || worker.status === "failed" || worker.status === "cancelled") continue; - - const status = await adapter.queryStatus(worker.jobId); - - const newStatus = mapJobStatusToWorkerStatus(status.status); - updated.workers[i] = { - ...worker, - status: newStatus, - exitCode: status.exitCode ?? worker.exitCode, - runtimeSec: status.runningTimeSec ?? worker.runtimeSec, - completedAt: (newStatus === "completed" || newStatus === "failed") - ? new Date().toISOString() - : worker.completedAt, - }; - } - - updated.status = computeGroupStatus(updated); - return updated; -} - -function mapJobStatusToWorkerStatus(jobStatus: JobStatus): WorkerRunStatus { - switch (jobStatus) { - case "pending": return "pending"; - case "queued": return "queued"; - case "running": return "running"; - case "completed": return "completed"; - case "failed": return "failed"; - case "cancelled": return "cancelled"; - default: return "running"; - } -} - -// ------------------------------------------------------------------- -// Result Collection — Fetch logs/metrics for completed workers -// ------------------------------------------------------------------- - -/** - * Collect results (metrics, logs, artifacts) for completed workers. - * Uses a results fetcher function that can be mocked for testing. - */ -export type ResultsFetcher = (worker: WorkerRun) => Promise<{ - metrics: Record<string, number>; - artifactPaths: string[]; - logTail: string; -}>; - -/** Default no-op fetcher (returns what's already on the worker). */ -const defaultFetcher: ResultsFetcher = async (worker) => ({ - metrics: worker.metrics, - artifactPaths: worker.artifactPaths, - logTail: worker.logTail, -}); - -let _resultsFetcher: ResultsFetcher = defaultFetcher; - -export function setResultsFetcher(fetcher: ResultsFetcher): void { - _resultsFetcher = fetcher; -} - -export function resetResultsFetcher(): void { - _resultsFetcher = defaultFetcher; -} - -export async function collectWorkerResults(group: ExperimentGroup): Promise<ExperimentGroup> { - const updated = { ...group, workers: [...group.workers] }; - - for (let i = 0; i < updated.workers.length; i++) { - const worker = updated.workers[i]; - if (worker.status !== "completed" && worker.status !== "failed") continue; - if (Object.keys(worker.metrics).length > 0) continue; // Already collected - - try { - const results = await _resultsFetcher(worker); - updated.workers[i] = { - ...worker, - metrics: results.metrics, - artifactPaths: results.artifactPaths, - logTail: results.logTail, - }; - } catch { - // If collection fails, keep what we have - } - } - - return updated; -} - -// ------------------------------------------------------------------- -// Execution Round — Full round lifecycle -// ------------------------------------------------------------------- - -/** - * Create a new execution round. - */ -export function createExecutionRound( - sessionId: string, - roundNumber: number, - plan: ValidationPlan, -): ExecutionRound { - return { - roundNumber, - sessionId, - planSnapshot: plan, - group: null, - validationResult: null, - analysisResult: null, - changesFromPrevious: [], - continueDecision: "pending", - decisionReason: "", - status: "planning", - startedAt: new Date().toISOString(), - completedAt: null, - }; -} - -/** - * Run the full validation + analysis cycle for a completed group. - * Returns the updated round with validation and (if needed) analysis results. - */ -export function validateAndAnalyzeRound(round: ExecutionRound): ExecutionRound { - if (!round.group || round.group.status === "pending" || round.group.status === "running") { - return round; - } - - // Aggregate results - const aggregated = aggregateWorkerResults(round.group); - const groupWithResult = { ...round.group, aggregatedResult: aggregated }; - - // Validate - const validationResult = validateExperimentResults(groupWithResult, aggregated); - - // Analyze if failed/inconclusive - let analysisResult: ExperimentAnalysisResult | null = null; - if (validationResult.verdict !== "pass") { - analysisResult = analyzeExperimentFailure(groupWithResult, aggregated, validationResult); - } - - return { - ...round, - group: groupWithResult, - validationResult, - analysisResult, - status: validationResult.verdict === "pass" ? "completed" : "analyzing", - }; -} - -// ------------------------------------------------------------------- -// Execution Lineage — Track across all rounds -// ------------------------------------------------------------------- - -/** - * Create a new execution lineage tracker. - */ -export function createExecutionLineage(sessionId: string, maxRounds: number): ExecutionLineage { - return { - sessionId, - rounds: [], - currentRound: 0, - maxRounds, - hypothesisFalsified: false, - consecutiveFailures: 0, - hasPassingRound: false, - cumulativeEvidence: [], - }; -} - -/** - * Add a completed round to the lineage and update counters. - */ -export function addRoundToLineage(lineage: ExecutionLineage, round: ExecutionRound): ExecutionLineage { - const updated = { ...lineage, rounds: [...lineage.rounds, round] }; - updated.currentRound = updated.rounds.length; - - // Update counters - if (round.validationResult?.verdict === "pass") { - updated.hasPassingRound = true; - updated.consecutiveFailures = 0; - } else { - updated.consecutiveFailures++; - } - - // Check for hypothesis falsification - if (round.analysisResult?.rootCauses.some( - c => c.category === "negative_scientific_result" && c.confidence > 0.7 - )) { - const priorNegative = lineage.rounds.some(r => - r.analysisResult?.rootCauses.some(c => c.category === "negative_scientific_result" && c.confidence > 0.7) - ); - if (priorNegative) { - updated.hypothesisFalsified = true; - } - } - - // Build cumulative evidence - if (round.validationResult) { - updated.cumulativeEvidence.push( - `Round ${round.roundNumber}: ${round.validationResult.verdict} — ${round.validationResult.reasons.join("; ")}` - ); - } - - return updated; -} - -/** - * Check whether the execution loop should stop. - */ -export function checkStopConditions(lineage: ExecutionLineage): { - shouldStop: boolean; - reason: string; -} { - // Max rounds reached - if (lineage.currentRound >= lineage.maxRounds) { - return { shouldStop: true, reason: `Maximum execution rounds (${lineage.maxRounds}) reached` }; - } - - // Hypothesis falsified - if (lineage.hypothesisFalsified) { - return { shouldStop: true, reason: "Hypothesis has been falsified by repeated negative scientific results" }; - } - - // Check repeated failure pattern - const stopCheck = shouldStopExecution( - lineage.rounds.map(r => ({ - validationResult: r.validationResult, - analysisResult: r.analysisResult, - })) - ); - if (stopCheck.shouldStop) { - return stopCheck; - } - - return { shouldStop: false, reason: "" }; -} - -// ------------------------------------------------------------------- -// Convenience: Build a simple fanout plan from validation plan -// ------------------------------------------------------------------- - -export function buildSimpleFanoutPlan( - parentSpec: ExperimentSpec, - strategy: WorkerDecompositionStrategy, - paramSpace: Array<{ name: string; values: unknown[] }>, - overrides?: Partial<WorkerFanoutPlan>, -): WorkerFanoutPlan { - const totalWorkers = paramSpace.reduce((acc, p) => acc * p.values.length, 1) || 1; - - return { - parentSpec, - strategy, - parameterSpace: paramSpace, - totalWorkers, - maxParallel: overrides?.maxParallel ?? totalWorkers, - pilotFirst: overrides?.pilotFirst ?? false, - dependencyType: overrides?.dependencyType ?? "independent", - validationCriteria: overrides?.validationCriteria ?? createDefaultValidationCriteria({ - minSuccessfulWorkers: Math.max(1, Math.ceil(totalWorkers * 0.5)), - }), - aggregationRules: overrides?.aggregationRules ?? createDefaultAggregationRules(), - estimatedTotalGPUHours: overrides?.estimatedTotalGPUHours ?? - totalWorkers * parentSpec.resources.gpu * 4, - perWorkerResources: overrides?.perWorkerResources ?? parentSpec.resources, - }; -} - -// ------------------------------------------------------------------- -// Summary for Main Brain -// ------------------------------------------------------------------- - -export function summarizeLineageForMainBrain(lineage: ExecutionLineage): string { - const lines: string[] = []; - - lines.push(`## Execution Lineage — ${lineage.rounds.length}/${lineage.maxRounds} rounds`); - lines.push(`Has passing round: ${lineage.hasPassingRound}`); - lines.push(`Consecutive failures: ${lineage.consecutiveFailures}`); - lines.push(`Hypothesis falsified: ${lineage.hypothesisFalsified}`); - lines.push(""); - - for (const round of lineage.rounds) { - const verdict = round.validationResult?.verdict ?? "pending"; - const workersInfo = round.group - ? `${round.group.workers.filter(w => w.status === "completed").length}/${round.group.workers.length} workers` - : "no workers"; - lines.push(`### Round ${round.roundNumber}: ${verdict} (${workersInfo})`); - if (round.validationResult?.reasons.length) { - lines.push(` Reasons: ${round.validationResult.reasons.slice(0, 3).join("; ")}`); - } - if (round.analysisResult) { - lines.push(` Analysis: ${round.analysisResult.primaryRecommendation}`); - } - if (round.changesFromPrevious.length > 0) { - lines.push(` Changes: ${round.changesFromPrevious.join("; ")}`); - } - } - - return lines.join("\n"); -} diff --git a/src/lib/deep-research/execution-validator.ts b/src/lib/deep-research/execution-validator.ts deleted file mode 100644 index 52b098bd..00000000 --- a/src/lib/deep-research/execution-validator.ts +++ /dev/null @@ -1,338 +0,0 @@ -// ============================================================= -// Execution Validator — Structured experiment output validation -// ============================================================= -// Checks whether experiment outputs satisfy acceptance criteria: -// - job completion status -// - required artifacts present -// - metrics meet thresholds -// - worker success rates -// - baseline comparisons -// - cross-seed variance - -import type { - ExperimentGroup, - WorkerRun, - AggregatedResult, - ValidationCriteria, - ExecutionValidationResult, - ValidationVerdict, -} from "./types"; - -// ------------------------------------------------------------------- -// Main validation function -// ------------------------------------------------------------------- - -/** - * Validate an experiment group's results against its criteria. - * Returns a structured verdict with reasons and suggestions. - */ -export function validateExperimentResults( - group: ExperimentGroup, - aggregated: AggregatedResult, - criteria?: ValidationCriteria, -): ExecutionValidationResult { - const effectiveCriteria = criteria ?? group.validationCriteria; - const criterionResults: ExecutionValidationResult["criterionResults"] = []; - const missingArtifacts: string[] = []; - const metricComparisons: ExecutionValidationResult["metricComparisons"] = []; - const reasons: string[] = []; - const blockers: string[] = []; - - // 1. Check worker success rate - const successRate = aggregated.totalWorkers > 0 - ? aggregated.succeededWorkers / aggregated.totalWorkers - : 0; - const minRequired = effectiveCriteria.minSuccessfulWorkers; - const workerCheckPassed = aggregated.succeededWorkers >= minRequired; - - criterionResults.push({ - criterion: "worker_success_rate", - passed: workerCheckPassed, - actual: `${aggregated.succeededWorkers}/${aggregated.totalWorkers} workers succeeded (${(successRate * 100).toFixed(0)}%)`, - expected: `>= ${minRequired} successful workers`, - note: workerCheckPassed ? "Sufficient workers completed" : "Too many workers failed", - }); - - if (!workerCheckPassed) { - blockers.push(`Only ${aggregated.succeededWorkers}/${aggregated.totalWorkers} workers succeeded (need ${minRequired})`); - } - - // 2. Check required artifacts - for (const pattern of effectiveCriteria.requiredArtifacts) { - const found = aggregated.allArtifactPaths.some(p => matchesPattern(p, pattern)); - criterionResults.push({ - criterion: `artifact:${pattern}`, - passed: found, - actual: found ? "found" : "missing", - expected: pattern, - note: found ? "" : `Required artifact pattern '${pattern}' not found in outputs`, - }); - if (!found) { - missingArtifacts.push(pattern); - blockers.push(`Missing required artifact: ${pattern}`); - } - } - - // 3. Check metric thresholds - for (const threshold of effectiveCriteria.metricThresholds) { - const metricData = aggregated.metrics[threshold.metric]; - if (!metricData) { - criterionResults.push({ - criterion: `metric:${threshold.metric}`, - passed: false, - actual: "not found", - expected: `${threshold.operator} ${threshold.value}`, - note: `Metric '${threshold.metric}' not found in results`, - }); - blockers.push(`Metric '${threshold.metric}' not found in experiment outputs`); - metricComparisons.push({ - metric: threshold.metric, - actual: NaN, - threshold: threshold.value, - operator: threshold.operator, - passed: false, - }); - continue; - } - - const actual = metricData.mean; - const passed = evaluateThreshold(actual, threshold.operator, threshold.value, threshold.upperBound); - - criterionResults.push({ - criterion: `metric:${threshold.metric}`, - passed, - actual: `${actual.toFixed(4)} (std=${metricData.std.toFixed(4)})`, - expected: formatThreshold(threshold.operator, threshold.value, threshold.upperBound), - note: passed ? "" : `Metric ${threshold.metric}=${actual.toFixed(4)} does not meet threshold`, - }); - - metricComparisons.push({ - metric: threshold.metric, - actual, - threshold: threshold.value, - operator: threshold.operator, - passed, - }); - - if (!passed) { - blockers.push(`Metric '${threshold.metric}' = ${actual.toFixed(4)}, expected ${formatThreshold(threshold.operator, threshold.value, threshold.upperBound)}`); - } - } - - // 4. Check cross-seed variance - if (effectiveCriteria.maxVariance !== null && effectiveCriteria.maxVariance !== undefined) { - for (const [metricName, metricData] of Object.entries(aggregated.metrics)) { - if (metricData.values.length < 2) continue; - const cv = metricData.coefficientOfVariation; - const varianceOk = cv <= effectiveCriteria.maxVariance; - - criterionResults.push({ - criterion: `variance:${metricName}`, - passed: varianceOk, - actual: `CV=${cv.toFixed(4)}`, - expected: `CV <= ${effectiveCriteria.maxVariance}`, - note: varianceOk ? "" : `High variance in ${metricName} across workers`, - }); - - if (!varianceOk) { - reasons.push(`High variance in '${metricName}' (CV=${cv.toFixed(4)}): results may not be stable`); - } - } - } - - // 5. Check baseline comparison - if (effectiveCriteria.baselineRequired && Object.keys(effectiveCriteria.baselineMetrics).length > 0) { - for (const [metricName, baselineValue] of Object.entries(effectiveCriteria.baselineMetrics)) { - const metricData = aggregated.metrics[metricName]; - if (!metricData) { - criterionResults.push({ - criterion: `baseline:${metricName}`, - passed: false, - actual: "not found", - expected: `> baseline (${baselineValue})`, - note: `Cannot compare to baseline: metric '${metricName}' missing`, - }); - blockers.push(`Baseline comparison failed: metric '${metricName}' not found`); - continue; - } - - const beatsBaseline = metricData.mean > baselineValue; - criterionResults.push({ - criterion: `baseline:${metricName}`, - passed: beatsBaseline, - actual: `${metricData.mean.toFixed(4)}`, - expected: `> ${baselineValue} (baseline)`, - note: beatsBaseline ? `Beats baseline by ${((metricData.mean - baselineValue) / baselineValue * 100).toFixed(1)}%` : "Below baseline", - }); - - if (!beatsBaseline) { - blockers.push(`Metric '${metricName}' = ${metricData.mean.toFixed(4)} does not beat baseline ${baselineValue}`); - } - } - } - - // 6. Custom conditions (noted but not auto-evaluated) - for (const condition of effectiveCriteria.customConditions) { - criterionResults.push({ - criterion: `custom:${condition.slice(0, 50)}`, - passed: true, // Cannot auto-evaluate; mark as needing human/LLM review - actual: "requires manual review", - expected: condition, - note: "Custom condition — needs human or LLM evaluation", - }); - reasons.push(`Custom condition pending review: ${condition}`); - } - - // Determine overall verdict - const failedCriteria = criterionResults.filter(c => !c.passed); - const hasBlockers = blockers.length > 0; - const hasCustomPending = effectiveCriteria.customConditions.length > 0; - - let verdict: ValidationVerdict; - let severity: ExecutionValidationResult["severity"]; - - if (failedCriteria.length === 0 && !hasCustomPending) { - verdict = "pass"; - severity = "none"; - reasons.push("All validation criteria met"); - } else if (hasBlockers) { - // Check if it's a hard fail or just inconclusive - const criticalFails = failedCriteria.filter(c => - c.criterion.startsWith("worker_") || c.criterion.startsWith("artifact:") - ); - if (criticalFails.length > 0) { - verdict = "fail"; - severity = "critical"; - } else if (failedCriteria.length > failedCriteria.length / 2) { - verdict = "fail"; - severity = "major"; - } else { - verdict = "inconclusive"; - severity = "minor"; - } - } else { - verdict = "inconclusive"; - severity = "minor"; - reasons.push("Some criteria could not be automatically evaluated"); - } - - // Build suggestions - const retrySuggestion = verdict === "fail" && blockers.some(b => b.includes("workers failed")) - ? "Some workers failed — consider rerunning with more resources or debugging the failing workers" - : null; - - const replanSuggestion = verdict === "fail" && blockers.some(b => b.includes("does not meet threshold")) - ? "Key metrics did not meet thresholds — consider revising the experiment design or hypothesis" - : null; - - const confidenceScore = criterionResults.length > 0 - ? criterionResults.filter(c => c.passed).length / criterionResults.length - : 0; - - return { - verdict, - confidenceScore, - criterionResults, - missingArtifacts, - metricComparisons, - reasons, - blockers, - retrySuggestion, - replanSuggestion, - severity, - validatedAt: new Date().toISOString(), - }; -} - -// ------------------------------------------------------------------- -// Quick validation for a single worker run -// ------------------------------------------------------------------- - -export function validateSingleWorker(worker: WorkerRun): { - passed: boolean; - reason: string; -} { - if (worker.status !== "completed") { - return { passed: false, reason: `Worker ${worker.workerId} status: ${worker.status}` }; - } - if (worker.exitCode !== null && worker.exitCode !== 0) { - return { passed: false, reason: `Worker ${worker.workerId} exit code: ${worker.exitCode}` }; - } - if (Object.keys(worker.metrics).length === 0) { - return { passed: false, reason: `Worker ${worker.workerId} produced no metrics` }; - } - return { passed: true, reason: "Worker completed successfully with metrics" }; -} - -// ------------------------------------------------------------------- -// Create default validation criteria -// ------------------------------------------------------------------- - -export function createDefaultValidationCriteria( - overrides?: Partial<ValidationCriteria>, -): ValidationCriteria { - return { - metricThresholds: [], - requiredArtifacts: [], - minSuccessfulWorkers: 1, - maxVariance: null, - baselineRequired: false, - baselineMetrics: {}, - customConditions: [], - ...overrides, - }; -} - -// ------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------- - -function evaluateThreshold( - actual: number, - operator: string, - value: number, - upperBound?: number, -): boolean { - switch (operator) { - case "gte": return actual >= value; - case "lte": return actual <= value; - case "gt": return actual > value; - case "lt": return actual < value; - case "eq": return Math.abs(actual - value) < 1e-9; - case "between": return actual >= value && actual <= (upperBound ?? Infinity); - default: return false; - } -} - -function formatThreshold(operator: string, value: number, upperBound?: number): string { - switch (operator) { - case "gte": return `>= ${value}`; - case "lte": return `<= ${value}`; - case "gt": return `> ${value}`; - case "lt": return `< ${value}`; - case "eq": return `== ${value}`; - case "between": return `${value} - ${upperBound}`; - default: return `${operator} ${value}`; - } -} - -function matchesPattern(path: string, pattern: string): boolean { - // Simple glob-like matching - if (pattern.startsWith("*")) { - return path.endsWith(pattern.slice(1)); - } - if (pattern.endsWith("*")) { - return path.startsWith(pattern.slice(0, -1)); - } - if (pattern.includes("*")) { - const parts = pattern.split("*"); - let idx = 0; - for (const part of parts) { - const found = path.indexOf(part, idx); - if (found < 0) return false; - idx = found + part.length; - } - return true; - } - return path.includes(pattern); -} diff --git a/src/lib/deep-research/experiment-analysis.ts b/src/lib/deep-research/experiment-analysis.ts deleted file mode 100644 index 7b3b03eb..00000000 --- a/src/lib/deep-research/experiment-analysis.ts +++ /dev/null @@ -1,358 +0,0 @@ -// ============================================================= -// Experiment Analysis — Diagnose failed/weak experiment outcomes -// ============================================================= -// Reads job logs, metrics, validation reports, and worker variance -// to produce structured root-cause analysis and recommendations. - -import type { - ExperimentGroup, - WorkerRun, - AggregatedResult, - ExecutionValidationResult, - ExperimentAnalysisResult, -} from "./types"; - -// ------------------------------------------------------------------- -// Main analysis function -// ------------------------------------------------------------------- - -/** - * Analyze a failed or inconclusive experiment group. - * Returns structured diagnosis with root causes and recommendations. - */ -export function analyzeExperimentFailure( - group: ExperimentGroup, - aggregated: AggregatedResult | null, - validationResult: ExecutionValidationResult, -): ExperimentAnalysisResult { - const rootCauses: ExperimentAnalysisResult["rootCauses"] = []; - const recommendations: ExperimentAnalysisResult["recommendations"] = []; - const suggestedFixes: ExperimentAnalysisResult["suggestedFixes"] = []; - - // Analyze each worker for failure patterns - const failedWorkers = group.workers.filter(w => w.status === "failed" || w.status === "timeout"); - const succeededWorkers = group.workers.filter(w => w.status === "completed"); - - // --- Pattern 1: OOM detection --- - const oomWorkers = failedWorkers.filter(w => isOOM(w)); - if (oomWorkers.length > 0) { - rootCauses.push({ - category: "oom", - description: `${oomWorkers.length}/${group.workers.length} workers failed with OOM`, - confidence: 0.95, - supportingEvidence: oomWorkers.map(w => `Worker ${w.label}: ${extractOOMInfo(w)}`), - }); - recommendations.push({ - action: "increase_resources", - reasoning: "Workers ran out of memory. Increase GPU memory or reduce batch size.", - estimatedEffort: "low", - requiredChanges: [ - "Increase memoryMb in resource spec", - "Or add --per_device_train_batch_size=<smaller> to training args", - "Or use gradient accumulation steps", - ], - }); - suggestedFixes.push({ - target: "resources.memoryMb", - fix: `Increase from ${group.parentSpec.resources.memoryMb} to ${Math.ceil(group.parentSpec.resources.memoryMb * 1.5)}`, - priority: "critical", - }); - } - - // --- Pattern 2: Timeout detection --- - const timeoutWorkers = failedWorkers.filter(w => w.status === "timeout" || isTimeout(w)); - if (timeoutWorkers.length > 0 && oomWorkers.length === 0) { - rootCauses.push({ - category: "timeout", - description: `${timeoutWorkers.length} workers timed out`, - confidence: 0.9, - supportingEvidence: timeoutWorkers.map(w => `Worker ${w.label}: runtime=${w.runtimeSec}s`), - }); - recommendations.push({ - action: "increase_resources", - reasoning: "Workers exceeded walltime. Increase walltime or use fewer training steps.", - estimatedEffort: "low", - requiredChanges: ["Increase walltime in resource spec", "Or reduce max_steps/epochs"], - }); - } - - // --- Pattern 3: Infrastructure/launcher failure --- - const infraFailures = failedWorkers.filter(w => isInfraFailure(w)); - if (infraFailures.length > 0) { - rootCauses.push({ - category: "infrastructure_failure", - description: `${infraFailures.length} workers failed due to infrastructure issues`, - confidence: 0.8, - supportingEvidence: infraFailures.map(w => `Worker ${w.label}: ${w.error?.slice(0, 200)}`), - }); - recommendations.push({ - action: "rerun_unchanged", - reasoning: "Infrastructure failure is typically transient. Rerunning may succeed.", - estimatedEffort: "low", - requiredChanges: [], - }); - } - - // --- Pattern 4: Data issues --- - const dataFailures = failedWorkers.filter(w => isDataIssue(w)); - if (dataFailures.length > 0) { - rootCauses.push({ - category: "data_issue", - description: "Workers failed due to data loading or format issues", - confidence: 0.85, - supportingEvidence: dataFailures.map(w => `Worker ${w.label}: ${extractDataError(w)}`), - }); - recommendations.push({ - action: "fix_data_pipeline", - reasoning: "Data pipeline errors need fixing before rerun.", - estimatedEffort: "medium", - requiredChanges: ["Check data format", "Verify dataset paths", "Review preprocessing"], - }); - } - - // --- Pattern 5: All workers succeeded but metrics are bad --- - if (failedWorkers.length === 0 && validationResult.verdict !== "pass") { - const metricFails = validationResult.metricComparisons.filter(m => !m.passed); - if (metricFails.length > 0) { - // Check if it's close to threshold (inconclusive) or far below (negative result) - const closeMisses = metricFails.filter(m => { - const ratio = m.actual / m.threshold; - return ratio > 0.8 && ratio < 1.0; - }); - - if (closeMisses.length > 0) { - rootCauses.push({ - category: "unstable_training", - description: "Metrics are close to but below thresholds — may need more training or tuning", - confidence: 0.7, - supportingEvidence: closeMisses.map(m => `${m.metric}: ${m.actual.toFixed(4)} vs threshold ${m.threshold}`), - }); - recommendations.push({ - action: "rerun_with_fixes", - reasoning: "Results are close to passing. Try with more training steps, better hyperparameters, or more data.", - estimatedEffort: "medium", - requiredChanges: ["Increase training steps", "Tune learning rate", "Check data quality"], - }); - } else { - rootCauses.push({ - category: "negative_scientific_result", - description: "Metrics are significantly below thresholds — hypothesis may be incorrect", - confidence: 0.6, - supportingEvidence: metricFails.map(m => `${m.metric}: ${m.actual.toFixed(4)} far below threshold ${m.threshold}`), - }); - recommendations.push({ - action: "redesign_experiment", - reasoning: "Results are far below expectations. The hypothesis may need revision.", - estimatedEffort: "high", - requiredChanges: ["Revise hypothesis", "Consider alternative approaches", "Check if baseline is correct"], - }); - } - } - - // Check missing artifacts - if (validationResult.missingArtifacts.length > 0) { - rootCauses.push({ - category: "implementation_bug", - description: `Expected outputs not produced: ${validationResult.missingArtifacts.join(", ")}`, - confidence: 0.75, - supportingEvidence: [`Missing: ${validationResult.missingArtifacts.join(", ")}`], - }); - suggestedFixes.push({ - target: "output_config", - fix: "Verify that scripts produce the expected output files at the expected paths", - priority: "high", - }); - } - } - - // --- Pattern 6: High variance across seeds --- - if (aggregated) { - const highVarianceMetrics = Object.entries(aggregated.metrics).filter( - ([_key, m]) => m.coefficientOfVariation > 0.3 && m.values.length >= 2 - ); - if (highVarianceMetrics.length > 0) { - rootCauses.push({ - category: "unstable_training", - description: `High variance across workers in: ${highVarianceMetrics.map(([k]) => k).join(", ")}`, - confidence: 0.65, - supportingEvidence: highVarianceMetrics.map( - ([k, m]) => `${k}: CV=${m.coefficientOfVariation.toFixed(3)}, range=[${m.min.toFixed(4)}, ${m.max.toFixed(4)}]` - ), - }); - recommendations.push({ - action: "rerun_with_fixes", - reasoning: "Results are unstable across seeds. Consider more seeds, longer training, or learning rate warmup.", - estimatedEffort: "medium", - requiredChanges: ["Add more seed runs", "Increase training duration", "Use learning rate warmup"], - }); - } - } - - // --- Pattern 7: Metric mismatch (metrics present but wrong names) --- - if (aggregated && validationResult.metricComparisons.some(m => isNaN(m.actual))) { - const missing = validationResult.metricComparisons.filter(m => isNaN(m.actual)).map(m => m.metric); - const available = Object.keys(aggregated.metrics); - rootCauses.push({ - category: "metric_mismatch", - description: `Expected metrics not found. Expected: [${missing.join(", ")}]. Available: [${available.join(", ")}]`, - confidence: 0.85, - supportingEvidence: [`Mismatched metric names between validation criteria and actual outputs`], - }); - suggestedFixes.push({ - target: "validation_criteria", - fix: `Update metric names. Available metrics: ${available.join(", ")}`, - priority: "high", - }); - } - - // If no specific cause found, classify as unknown - if (rootCauses.length === 0) { - rootCauses.push({ - category: "unknown", - description: "No specific failure pattern detected. Manual investigation needed.", - confidence: 0.3, - supportingEvidence: failedWorkers.map(w => `Worker ${w.label}: status=${w.status}, error=${w.error?.slice(0, 100) ?? "none"}`), - }); - recommendations.push({ - action: "rerun_unchanged", - reasoning: "No clear failure pattern. A rerun may help determine if the issue is transient.", - estimatedEffort: "low", - requiredChanges: [], - }); - } - - // Sort by confidence - rootCauses.sort((a, b) => b.confidence - a.confidence); - - // Determine primary recommendation - const primaryRecommendation = recommendations[0]?.action ?? "rerun_unchanged"; - const shouldRerun = primaryRecommendation === "rerun_unchanged" || primaryRecommendation === "rerun_with_fixes"; - const shouldRedesign = primaryRecommendation === "redesign_experiment" || primaryRecommendation === "pivot_hypothesis"; - const shouldStop = primaryRecommendation === "stop_research"; - - // Build summary - const summaryParts = [ - `Experiment group ${group.groupId} round ${group.roundNumber}: ${validationResult.verdict}`, - `Workers: ${succeededWorkers.length} succeeded, ${failedWorkers.length} failed out of ${group.workers.length}`, - `Top root cause: ${rootCauses[0]?.category} (confidence: ${(rootCauses[0]?.confidence * 100).toFixed(0)}%)`, - `Recommendation: ${primaryRecommendation}`, - ]; - if (validationResult.blockers.length > 0) { - summaryParts.push(`Blockers: ${validationResult.blockers.slice(0, 3).join("; ")}`); - } - - return { - analysisId: `analysis-${group.groupId}-r${group.roundNumber}`, - groupId: group.groupId, - roundNumber: group.roundNumber, - rootCauses, - primaryRecommendation, - recommendations, - shouldRerun, - shouldRedesign, - shouldStop, - suggestedFixes, - summaryForMainBrain: summaryParts.join("\n"), - analyzedAt: new Date().toISOString(), - }; -} - -// ------------------------------------------------------------------- -// Pattern detection helpers -// ------------------------------------------------------------------- - -function isOOM(worker: WorkerRun): boolean { - const signals = [ - worker.exitCode === 137, - worker.exitCode === 9, - /out of memory|oom|cuda.*out.*memory|memory.*alloc/i.test(worker.logTail), - /RuntimeError.*CUDA/i.test(worker.logTail), - ]; - return signals.some(Boolean); -} - -function extractOOMInfo(worker: WorkerRun): string { - const match = worker.logTail.match(/(?:out of memory|oom|cuda.*memory)[^\n]*/i); - return match ? match[0].slice(0, 200) : `exit code ${worker.exitCode}`; -} - -function isTimeout(worker: WorkerRun): boolean { - return /timeout|time.?limit|walltime.*exceed/i.test(worker.logTail) || - worker.exitCode === 124; -} - -function isInfraFailure(worker: WorkerRun): boolean { - const patterns = [ - /connection.*refused|ssh.*fail|host.*not.*found/i, - /node.*unavailable|partition.*down/i, - /resource.*unavailable|allocation.*fail/i, - /slurm.*error|rjob.*error.*submit/i, - /nccl.*error|distributed.*init.*fail/i, - ]; - const text = `${worker.logTail} ${worker.error ?? ""}`; - return patterns.some(p => p.test(text)); -} - -function isDataIssue(worker: WorkerRun): boolean { - const patterns = [ - /file.*not.*found|no.*such.*file/i, - /data.*load.*error|dataset.*error/i, - /json.*decode.*error|csv.*parse/i, - /corrupt|checksum.*mismatch/i, - /permission.*denied.*data/i, - ]; - const text = `${worker.logTail} ${worker.error ?? ""}`; - return patterns.some(p => p.test(text)); -} - -function extractDataError(worker: WorkerRun): string { - const text = `${worker.logTail} ${worker.error ?? ""}`; - const match = text.match(/(?:file.*not.*found|data.*error|dataset.*error|corrupt)[^\n]*/i); - return match ? match[0].slice(0, 200) : "Data-related failure"; -} - -// ------------------------------------------------------------------- -// Convenience: should we stop after repeated failures? -// ------------------------------------------------------------------- - -export function shouldStopExecution( - rounds: Array<{ validationResult: ExecutionValidationResult | null; analysisResult: ExperimentAnalysisResult | null }>, - maxConsecutiveFailures = 3, -): { shouldStop: boolean; reason: string } { - let consecutiveFailures = 0; - - for (let i = rounds.length - 1; i >= 0; i--) { - const vr = rounds[i].validationResult; - if (!vr || vr.verdict === "fail") { - consecutiveFailures++; - } else { - break; - } - } - - if (consecutiveFailures >= maxConsecutiveFailures) { - return { - shouldStop: true, - reason: `${consecutiveFailures} consecutive failed rounds. Repeated failures suggest a fundamental issue.`, - }; - } - - // Check if the latest analysis recommends stopping - const latest = rounds[rounds.length - 1]?.analysisResult; - if (latest?.shouldStop) { - return { shouldStop: true, reason: latest.summaryForMainBrain }; - } - - // Check for hypothesis falsification - const negativeResults = rounds.filter( - r => r.analysisResult?.rootCauses.some(c => c.category === "negative_scientific_result" && c.confidence > 0.7) - ); - if (negativeResults.length >= 2) { - return { - shouldStop: true, - reason: "Multiple rounds indicate a negative scientific result. The hypothesis may be falsified.", - }; - } - - return { shouldStop: false, reason: "" }; -} diff --git a/src/lib/deep-research/final-report-prompt.test.ts b/src/lib/deep-research/final-report-prompt.test.ts new file mode 100644 index 00000000..ee91ebee --- /dev/null +++ b/src/lib/deep-research/final-report-prompt.test.ts @@ -0,0 +1,534 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeFinalReportCitationCoverage, + appendDeterministicReferencesSection, + assembleFinalReportFromSections, + buildFinalReportPlannerSystemPrompt, + buildFinalReportPromptBundle, + buildFinalReportSectionCitationRevisionPrompt, + buildFinalReportSectionDraftPrompt, + buildFinalReportPrompt, + extractRecognizedCitationKeys, + getRelevantChapterPacketsForSection, + getFinalReportDraftingOrder, + getMinimumRequiredCitationCount, + normalizeFinalReportSectionPlan, +} from "./prompts"; +import type { + DeepResearchArtifact, + DeepResearchMessage, + DeepResearchNode, + DeepResearchSession, +} from "./types"; + +function createSession(): DeepResearchSession { + return { + id: "session-1", + workspaceId: "workspace-1", + title: "时间序列 Transformer 架构综述", + status: "running", + contextTag: "final_report", + config: { + budget: { maxTotalTokens: 100000, maxOpusTokens: 100000 }, + maxWorkerFanOut: 1, + maxReviewerRounds: 2, + maxExecutionLoops: 1, + maxWorkerConcurrency: 1, + literature: { + maxLiteratureRounds: 3, + maxPapersPerRound: 10, + maxTotalPapers: 30, + maxReviewerRequestedExpansionRounds: 1, + maxSearchRetries: 2, + }, + execution: { + defaultLauncherType: "local_shell", + defaultResources: { gpu: 0, memoryMb: 0, cpu: 1, privateMachine: "no" }, + defaultMounts: [], + defaultChargedGroup: "", + }, + }, + budget: { + totalTokens: 1000, + opusTokens: 0, + byRole: {}, + byNode: {}, + }, + pendingCheckpointId: null, + literatureRound: 1, + reviewerRound: 0, + executionLoop: 0, + error: null, + remoteProfileId: null, + createdAt: "2026-04-15T00:00:00.000Z", + updatedAt: "2026-04-15T00:00:00.000Z", + }; +} + +function createNode(): DeepResearchNode { + return { + id: "node-1", + sessionId: "session-1", + parentId: null, + nodeType: "final_report", + label: "Generate final research report", + status: "pending", + assignedRole: "research_asset_reuse_specialist", + assignedModel: null, + input: { + targetAudience: "research engineers", + }, + output: null, + error: null, + dependsOn: [], + supersedesId: null, + supersededById: null, + branchKey: null, + retryOfId: null, + retryCount: 0, + contextTag: "final_report", + stageNumber: 0, + requiresConfirmation: true, + confirmedAt: null, + confirmedBy: null, + confirmationOutcome: null, + positionX: null, + positionY: null, + startedAt: null, + completedAt: null, + createdAt: "2026-04-15T00:00:00.000Z", + updatedAt: "2026-04-15T00:00:00.000Z", + }; +} + +function createMessage(content: string): DeepResearchMessage { + return { + id: "message-1", + sessionId: "session-1", + role: "user", + content, + metadata: null, + relatedNodeId: null, + relatedArtifactIds: [], + createdAt: "2026-04-15T00:00:00.000Z", + }; +} + +function createArtifact(overrides: Partial<DeepResearchArtifact>): DeepResearchArtifact { + return { + id: overrides.id ?? "artifact-1", + sessionId: overrides.sessionId ?? "session-1", + nodeId: overrides.nodeId ?? "node-upstream", + artifactType: overrides.artifactType ?? "evidence_card", + title: overrides.title ?? "Evidence Card", + content: overrides.content ?? {}, + provenance: overrides.provenance ?? null, + version: overrides.version ?? 1, + createdAt: overrides.createdAt ?? "2026-04-15T00:00:00.000Z", + }; +} + +describe("buildFinalReportPrompt", () => { + it("requires a detailed survey-style markdown report for review requests", () => { + const prompt = buildFinalReportPrompt( + createSession(), + [createMessage("请给我一个详细的时间序列 Transformer 架构综述,强调分类、代表模型和局限。")], + [ + createArtifact({ + artifactType: "evidence_card", + title: "Evidence: Collect papers", + content: { + query: "time series transformer survey", + sourcesFound: 3, + coverageSummary: "Collected representative papers on forecasting, anomaly detection, and long-horizon modeling.", + sources: [ + { title: "Informer", year: 2021, relevance: "efficient long-sequence forecasting", url: "https://arxiv.org/abs/2012.07436", venue: "AAAI" }, + { title: "Autoformer", year: 2021, relevance: "decomposition and autocorrelation", url: "https://arxiv.org/abs/2106.13008", venue: "NeurIPS" }, + ], + }, + }), + ], + createNode(), + ); + + expect(prompt).toContain("Produce a detailed markdown report, not a short note."); + expect(prompt).toContain("时间序列 Transformer 架构谱系 / 分类框架"); + expect(prompt).toContain("代表性模型与关键设计选择比较"); + expect(prompt).toContain("Representative sources"); + expect(prompt).toContain("请直接使用中文撰写报告"); + expect(prompt).toContain("Use inline citations throughout the report"); + expect(prompt).toContain("- [Informer, 2021]"); + expect(prompt).toContain("- [Autoformer, 2021]"); + expect(prompt).toContain("References"); + expect(prompt).toContain("query=time series transformer survey"); + expect(prompt).toContain("Minimum unique citations expected in this report"); + expect(prompt).toContain("The paper must be detailed, complete, and logically strong."); + expect(prompt).toContain("Prefer paragraph-level argument development: claim -> evidence -> comparison -> implication."); + }); + + it("computes a higher citation floor for large survey evidence pools", () => { + expect(getMinimumRequiredCitationCount(118, true)).toBeGreaterThanOrEqual(30); + expect(getMinimumRequiredCitationCount(10, true)).toBe(10); + expect(getMinimumRequiredCitationCount(10, false)).toBe(8); + }); + + it("detects under-covered reports against the citation registry", () => { + const artifacts = [ + createArtifact({ + artifactType: "evidence_card", + title: "Evidence: Collect papers", + content: { + query: "time series transformer survey", + sourcesFound: 4, + coverageSummary: "Collected representative papers.", + sources: [ + { title: "Informer", year: 2021, url: "https://arxiv.org/abs/2012.07436" }, + { title: "Autoformer", year: 2021, url: "https://arxiv.org/abs/2106.13008" }, + { title: "FEDformer", year: 2022, url: "https://arxiv.org/abs/2201.12740" }, + { title: "PatchTST", year: 2023, url: "https://arxiv.org/abs/2211.14730" }, + ], + }, + }), + ]; + + const coverage = analyzeFinalReportCitationCoverage( + "# Report\n\nThis section cites [Informer, 2021] and [Autoformer, 2021].", + artifacts, + "时间序列 Transformer 架构综述", + ["请写综述"], + ); + + expect(coverage.availableCitationCount).toBe(4); + expect(coverage.citedCitationCount).toBe(2); + expect(coverage.hasReferencesSection).toBe(false); + expect(coverage.meetsCoverage).toBe(false); + expect(coverage.missingCitationKeys).toContain("FEDformer, 2022"); + }); + + it("normalizes section plans and drafts introduction/conclusion last", () => { + const sectionPlan = normalizeFinalReportSectionPlan({ + rawPlan: { + reportTitle: "时间序列 Transformer 架构综述", + sections: [ + { id: "intro", title: "引言", kind: "introduction", summary: "介绍背景", targetTakeaway: "明确范围", citationFocus: ["background"] }, + { id: "body-1", title: "架构谱系与路线", kind: "body", summary: "分类技术路线", targetTakeaway: "给出 taxonomy", citationFocus: ["taxonomy"] }, + { id: "body-2", title: "代表模型比较", kind: "body", summary: "比较模型", targetTakeaway: "解释设计差异", citationFocus: ["representative methods"] }, + { id: "conclusion", title: "结论", kind: "conclusion", summary: "总结全文", targetTakeaway: "收束结论", citationFocus: ["synthesis"] }, + ], + }, + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + isSurveyLikeRequest: true, + }); + + const draftingOrder = getFinalReportDraftingOrder(sectionPlan); + expect(draftingOrder.map((section) => section.kind)).toEqual([ + "body", + "body", + "introduction", + "conclusion", + ]); + + const report = assembleFinalReportFromSections({ + reportTitle: sectionPlan.reportTitle, + sectionPlan, + sectionDrafts: new Map([ + ["intro", "## 引言\n导论内容"], + ["body-1", "## 架构谱系与路线\n主体一"], + ["body-2", "## 代表模型比较\n主体二"], + ["conclusion", "## 结论\n总结内容"], + ]), + }); + + expect(report).toContain("# 时间序列 Transformer 架构综述"); + expect(report.indexOf("## 引言")).toBeLessThan(report.indexOf("## 架构谱系与路线")); + expect(report.indexOf("## 结论")).toBeGreaterThan(report.indexOf("## 代表模型比较")); + }); + + it("requires academically strong planning and section drafting prompts", () => { + const plannerPrompt = buildFinalReportPlannerSystemPrompt(createNode()); + expect(plannerPrompt).toContain("The outline must be complete enough to support a full paper-level draft"); + expect(plannerPrompt).toContain("The full outline should progress logically"); + + const sectionPlan = normalizeFinalReportSectionPlan({ + rawPlan: { + reportTitle: "时间序列 Transformer 架构综述", + sections: [ + { id: "body-1", title: "架构谱系与路线", kind: "body", summary: "分类技术路线", targetTakeaway: "给出 taxonomy", citationFocus: ["taxonomy"] }, + { id: "intro", title: "引言", kind: "introduction", summary: "介绍背景", targetTakeaway: "明确范围", citationFocus: ["background"] }, + { id: "conclusion", title: "结论", kind: "conclusion", summary: "总结全文", targetTakeaway: "收束结论", citationFocus: ["synthesis"] }, + ], + }, + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + isSurveyLikeRequest: true, + }); + + const bodyPrompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + section: sectionPlan.sections.find((section) => section.kind === "body")!, + sectionPlan, + artifactDigest: "evidence digest", + citationRegistry: "citation registry", + }); + expect(bodyPrompt).toContain("Write this as a substantial academic section"); + expect(bodyPrompt).toContain("Use coherent paragraphs with strong internal logical flow"); + + const introductionPrompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + section: sectionPlan.sections.find((section) => section.kind === "introduction")!, + sectionPlan, + artifactDigest: "evidence digest", + citationRegistry: "citation registry", + draftedBodySections: [{ title: "架构谱系与路线", content: "正文" }], + }); + expect(introductionPrompt).toContain("Write a full academic introduction"); + + const conclusionPrompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + section: sectionPlan.sections.find((section) => section.kind === "conclusion")!, + sectionPlan, + artifactDigest: "evidence digest", + citationRegistry: "citation registry", + draftedFullSections: [{ title: "架构谱系与路线", content: "正文" }], + }); + expect(conclusionPrompt).toContain("The conclusion must synthesize the entire paper"); + }); + + it("blocks survey-style drafting when the evidence base is too thin", () => { + const bundle = buildFinalReportPromptBundle( + createSession(), + [createMessage("请写一个系统综述,覆盖代表模型和实验规律。")], + [ + createArtifact({ + artifactType: "evidence_card", + title: "Evidence: single paper", + content: { + query: "time series transformer", + sourcesFound: 1, + sources: [ + { title: "Informer", year: 2021, url: "https://arxiv.org/abs/2012.07436" }, + ], + }, + }), + ], + { digestMode: "compact" }, + ); + + expect(bundle.readiness.canDraft).toBe(false); + expect(bundle.readiness.status).toBe("insufficient_evidence"); + expect(bundle.readiness.recommendedAction).toContain("targeted"); + }); + + it("caps overly granular body plans to a safer number of sections", () => { + const sectionPlan = normalizeFinalReportSectionPlan({ + rawPlan: { + reportTitle: "时间序列 Transformer 架构综述", + sections: [ + { id: "intro", title: "引言", kind: "introduction", summary: "介绍背景", targetTakeaway: "明确范围", citationFocus: ["background"] }, + { id: "body-1", title: "一", kind: "body", summary: "一", targetTakeaway: "一", citationFocus: ["a"] }, + { id: "body-2", title: "二", kind: "body", summary: "二", targetTakeaway: "二", citationFocus: ["b"] }, + { id: "body-3", title: "三", kind: "body", summary: "三", targetTakeaway: "三", citationFocus: ["c"] }, + { id: "body-4", title: "四", kind: "body", summary: "四", targetTakeaway: "四", citationFocus: ["d"] }, + { id: "body-5", title: "五", kind: "body", summary: "五", targetTakeaway: "五", citationFocus: ["e"] }, + { id: "conclusion", title: "结论", kind: "conclusion", summary: "总结全文", targetTakeaway: "收束结论", citationFocus: ["synthesis"] }, + ], + }, + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + isSurveyLikeRequest: true, + maxBodySections: 3, + }); + + expect(sectionPlan.sections.filter((section) => section.kind === "body")).toHaveLength(3); + expect(sectionPlan.sections.map((section) => section.id)).toEqual([ + "intro", + "body-1", + "body-2", + "body-3", + "conclusion", + ]); + }); + + it("truncates long drafted-section references in section prompts", () => { + const longContent = "A".repeat(2000); + const sectionPlan = normalizeFinalReportSectionPlan({ + rawPlan: { + reportTitle: "时间序列 Transformer 架构综述", + sections: [ + { id: "intro", title: "引言", kind: "introduction", summary: "介绍背景", targetTakeaway: "明确范围", citationFocus: ["background"] }, + { id: "body-1", title: "主体", kind: "body", summary: "主体总结", targetTakeaway: "主体结论", citationFocus: ["taxonomy"] }, + { id: "conclusion", title: "结论", kind: "conclusion", summary: "总结全文", targetTakeaway: "收束结论", citationFocus: ["synthesis"] }, + ], + }, + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + isSurveyLikeRequest: true, + }); + + const prompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + section: sectionPlan.sections[0], + sectionPlan, + artifactDigest: "digest", + citationRegistry: "registry", + draftedBodySections: [{ title: "主体", content: longContent }], + referenceExcerptLimit: 80, + }); + + expect(prompt).toContain("### 主体"); + expect(prompt).toContain("A".repeat(40)); + expect(prompt).not.toContain("A".repeat(500)); + }); + + it("prioritizes explicitly referenced artifacts in the final-report digest", () => { + const bundle = buildFinalReportPromptBundle( + createSession(), + [createMessage("请生成终稿")], + [ + createArtifact({ + id: "artifact-evidence", + artifactType: "evidence_card", + title: "Evidence: 主要证据", + content: { + query: "time series transformer", + sourcesFound: 2, + sources: [ + { title: "Informer", year: 2021, url: "https://arxiv.org/abs/2012.07436" }, + { title: "iTransformer", year: 2024, url: "https://arxiv.org/abs/2310.06625" }, + ], + }, + }), + createArtifact({ + id: "artifact-summary", + artifactType: "structured_summary", + title: "Summary: 架构综述", + content: { + summary: "这是显式指定给 final_report 的结构化总结。", + }, + }), + ], + { + preferredArtifactIds: ["artifact-summary"], + }, + ); + + expect(bundle.artifactDigest).toContain("Summary: 架构综述"); + expect(bundle.artifactDigest.indexOf("Summary: 架构综述")).toBeLessThan( + bundle.artifactDigest.indexOf("Evidence: 主要证据"), + ); + }); + + it("adds a deterministic references section when the draft lacks one", () => { + const citationEntries = [ + { citationKey: "Informer, 2021", title: "Informer", year: 2021, url: "https://arxiv.org/abs/2012.07436", query: "time series transformer" }, + { citationKey: "Autoformer, 2021", title: "Autoformer", year: 2021, url: "https://arxiv.org/abs/2106.13008", query: "time series transformer" }, + ]; + + const appended = appendDeterministicReferencesSection({ + reportText: "# Report\n\nSection text citing [Informer, 2021].", + citationEntries, + preferredOutputLanguage: "en", + }); + + expect(appended.reportText).toContain("## References"); + expect(appended.reportText).toContain("[Informer, 2021]"); + expect(appended.referencesAdded).toBe(true); + }); + + it("still appends references when the draft contains no recognized citations", () => { + const citationEntries = [ + { citationKey: "Informer, 2021", title: "Informer", year: 2021, url: "https://arxiv.org/abs/2012.07436", query: "time series transformer" }, + { citationKey: "Autoformer, 2021", title: "Autoformer", year: 2021, url: "https://arxiv.org/abs/2106.13008", query: "time series transformer" }, + ]; + + const appended = appendDeterministicReferencesSection({ + reportText: "# Report\n\nSection text with no inline citations yet.", + citationEntries, + preferredOutputLanguage: "en", + minimumReferenceCount: 2, + }); + + expect(appended.reportText).toContain("## References"); + expect(appended.reportText).toContain("[Informer, 2021]"); + expect(appended.reportText).toContain("[Autoformer, 2021]"); + expect(appended.referencesAdded).toBe(true); + }); + + it("extracts recognized citation keys and exposes relevant chapter packets", () => { + const artifacts = [ + createArtifact({ + artifactType: "structured_summary", + title: "Summary: 架构综述", + content: { + summary: "summary", + chapterPackets: [ + { + id: "chapter_1", + title: "架构谱系与路线", + objective: "taxonomy", + summary: "梳理 Informer 与 Autoformer 的稀疏注意力谱系", + keyTakeaways: ["稀疏注意力路线"], + claims: [], + supportingQuotes: [], + citationKeys: ["Informer, 2021", "Autoformer, 2021"], + openQuestions: [], + recommendedSectionText: "Section seed [Informer, 2021].", + }, + ], + }, + }), + ]; + + const sectionPlan = normalizeFinalReportSectionPlan({ + rawPlan: { + reportTitle: "时间序列 Transformer 架构综述", + sections: [ + { id: "body-1", title: "架构谱系与路线", kind: "body", summary: "分类技术路线", targetTakeaway: "给出 taxonomy", citationFocus: ["taxonomy", "Informer"] }, + ], + }, + sessionTitle: "时间序列 Transformer 架构综述", + preferredOutputLanguage: "zh", + isSurveyLikeRequest: true, + }); + + const packets = getRelevantChapterPacketsForSection({ + section: sectionPlan.sections[1]!, + artifacts, + }); + + expect(packets).toHaveLength(1); + expect(extractRecognizedCitationKeys("正文 [Informer, 2021]", citationEntriesFromArtifacts(artifacts))).toEqual(["Informer, 2021"]); + + const prompt = buildFinalReportSectionCitationRevisionPrompt({ + sectionTitle: "架构谱系与路线", + preferredOutputLanguage: "zh", + existingSection: "## 架构谱系与路线\n正文", + relevantPackets: packets, + allowedCitationKeys: ["Informer, 2021", "Autoformer, 2021"], + }); + + expect(prompt).toContain("Allowed Citation Keys"); + expect(prompt).toContain("Informer, 2021"); + }); +}); + +function citationEntriesFromArtifacts(artifacts: DeepResearchArtifact[]) { + return artifacts.flatMap((artifact) => { + if (!Array.isArray(artifact.content.chapterPackets)) { + return []; + } + + return artifact.content.chapterPackets.flatMap((packet) => { + if (!packet || typeof packet !== "object" || !Array.isArray((packet as Record<string, unknown>).citationKeys)) { + return []; + } + + return ((packet as Record<string, unknown>).citationKeys as string[]).map((citationKey) => ({ citationKey })); + }); + }); +} diff --git a/src/lib/deep-research/final-report-retry-policy.test.ts b/src/lib/deep-research/final-report-retry-policy.test.ts new file mode 100644 index 00000000..8abd0153 --- /dev/null +++ b/src/lib/deep-research/final-report-retry-policy.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { assessFinalReportRetry } from "./final-report-retry-policy"; +import type { DeepResearchArtifact, DeepResearchNode } from "./types"; + +function createNode(overrides: Partial<DeepResearchNode>): DeepResearchNode { + return { + id: overrides.id ?? "node-1", + sessionId: overrides.sessionId ?? "session-1", + parentId: overrides.parentId ?? null, + nodeType: overrides.nodeType ?? "final_report", + label: overrides.label ?? "Generate final report", + status: overrides.status ?? "failed", + assignedRole: overrides.assignedRole ?? "research_asset_reuse_specialist", + assignedModel: overrides.assignedModel ?? null, + input: overrides.input ?? null, + output: overrides.output ?? null, + error: overrides.error ?? null, + dependsOn: overrides.dependsOn ?? [], + supersedesId: overrides.supersedesId ?? null, + supersededById: overrides.supersededById ?? null, + branchKey: overrides.branchKey ?? null, + retryOfId: overrides.retryOfId ?? null, + retryCount: overrides.retryCount ?? 0, + contextTag: overrides.contextTag ?? "final_report", + stageNumber: overrides.stageNumber ?? 0, + requiresConfirmation: overrides.requiresConfirmation ?? true, + confirmedAt: overrides.confirmedAt ?? null, + confirmedBy: overrides.confirmedBy ?? null, + confirmationOutcome: overrides.confirmationOutcome ?? null, + positionX: overrides.positionX ?? null, + positionY: overrides.positionY ?? null, + startedAt: overrides.startedAt ?? null, + completedAt: overrides.completedAt ?? null, + createdAt: overrides.createdAt ?? "2026-04-16T00:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-04-16T00:00:00.000Z", + }; +} + +function createArtifact(overrides: Partial<DeepResearchArtifact>): DeepResearchArtifact { + return { + id: overrides.id ?? "artifact-1", + sessionId: overrides.sessionId ?? "session-1", + nodeId: overrides.nodeId ?? "node-upstream", + artifactType: overrides.artifactType ?? "structured_summary", + title: overrides.title ?? "Summary", + content: overrides.content ?? {}, + provenance: overrides.provenance ?? null, + version: overrides.version ?? 1, + createdAt: overrides.createdAt ?? "2026-04-16T00:00:00.000Z", + }; +} + +describe("final-report retry policy", () => { + it("blocks repeated final_report retries when no new supporting artifact exists", () => { + const assessment = assessFinalReportRetry({ + nodes: [ + createNode({ id: "fr-1", createdAt: "2026-04-16T08:00:00.000Z" }), + createNode({ id: "fr-2", createdAt: "2026-04-16T09:00:00.000Z" }), + ], + artifacts: [ + createArtifact({ id: "sum-1", createdAt: "2026-04-16T07:00:00.000Z" }), + ], + }); + + expect(assessment.allowed).toBe(false); + expect(assessment.failedAttemptCount).toBe(2); + expect(assessment.reason).toContain("no new evidence/synthesis artifact"); + }); + + it("allows another final_report attempt after new synthesis material is added", () => { + const assessment = assessFinalReportRetry({ + nodes: [ + createNode({ id: "fr-1", createdAt: "2026-04-16T08:00:00.000Z" }), + createNode({ id: "fr-2", createdAt: "2026-04-16T09:00:00.000Z" }), + ], + artifacts: [ + createArtifact({ id: "sum-1", createdAt: "2026-04-16T07:00:00.000Z" }), + createArtifact({ id: "sum-2", createdAt: "2026-04-16T09:30:00.000Z" }), + ], + }); + + expect(assessment.allowed).toBe(true); + expect(assessment.newSupportingArtifactCount).toBe(1); + }); + + it("does not block a retry when the latest failure only happened in coverage revision", () => { + const assessment = assessFinalReportRetry({ + nodes: [ + createNode({ + id: "fr-1", + createdAt: "2026-04-16T08:00:00.000Z", + error: "All models failed. Last error: Final report coverage_revision failed in standard mode.", + }), + createNode({ + id: "fr-2", + createdAt: "2026-04-16T09:00:00.000Z", + error: "All models failed. Last error: Final report coverage_revision failed in standard mode.", + }), + ], + artifacts: [ + createArtifact({ id: "sum-1", createdAt: "2026-04-16T07:00:00.000Z" }), + ], + }); + + expect(assessment.allowed).toBe(true); + expect(assessment.failedAttemptCount).toBe(0); + }); +}); diff --git a/src/lib/deep-research/final-report-retry-policy.ts b/src/lib/deep-research/final-report-retry-policy.ts new file mode 100644 index 00000000..5fadeaad --- /dev/null +++ b/src/lib/deep-research/final-report-retry-policy.ts @@ -0,0 +1,92 @@ +import type { DeepResearchArtifact, DeepResearchNode } from "./types"; + +const SUPPORTING_FINAL_REPORT_ARTIFACT_TYPES = new Set<DeepResearchArtifact["artifactType"]>([ + "evidence_card", + "structured_summary", + "provisional_conclusion", + "review_assessment", + "reviewer_packet", + "literature_round_summary", + "claim_map", + "validation_report", + "experiment_result", + "step_result", +]); + +export interface FinalReportRetryAssessment { + allowed: boolean; + failedAttemptCount: number; + newSupportingArtifactCount: number; + latestFailureNodeId: string | null; + reason?: string; +} + +export function assessFinalReportRetry(input: { + nodes: DeepResearchNode[]; + artifacts: DeepResearchArtifact[]; + maxRepeatedFailuresWithoutNewMaterial?: number; +}): FinalReportRetryAssessment { + const maxRepeatedFailuresWithoutNewMaterial = input.maxRepeatedFailuresWithoutNewMaterial ?? 2; + const failedFinalReports = input.nodes + .filter((node) => node.nodeType === "final_report" && node.status === "failed") + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + + if (failedFinalReports.length === 0) { + return { + allowed: true, + failedAttemptCount: 0, + newSupportingArtifactCount: 0, + latestFailureNodeId: null, + }; + } + + const latestSupportingArtifactAt = input.artifacts + .filter((artifact) => SUPPORTING_FINAL_REPORT_ARTIFACT_TYPES.has(artifact.artifactType)) + .map((artifact) => artifact.createdAt) + .sort() + .at(-1); + const latestFailure = failedFinalReports[failedFinalReports.length - 1]; + const latestFailureWasCoverageOnly = /coverage_revision/i.test(latestFailure.error ?? ""); + + if (latestFailureWasCoverageOnly) { + return { + allowed: true, + failedAttemptCount: 0, + newSupportingArtifactCount: 0, + latestFailureNodeId: latestFailure.id, + }; + } + + if (latestSupportingArtifactAt && latestSupportingArtifactAt > latestFailure.createdAt) { + return { + allowed: true, + failedAttemptCount: 0, + newSupportingArtifactCount: input.artifacts.filter( + (artifact) => + SUPPORTING_FINAL_REPORT_ARTIFACT_TYPES.has(artifact.artifactType) + && artifact.createdAt > latestFailure.createdAt, + ).length, + latestFailureNodeId: latestFailure.id, + }; + } + + const resetTimestamp = latestSupportingArtifactAt ?? ""; + const failedAttemptCount = failedFinalReports.filter((node) => node.createdAt >= resetTimestamp).length; + + if (failedAttemptCount < maxRepeatedFailuresWithoutNewMaterial) { + return { + allowed: true, + failedAttemptCount, + newSupportingArtifactCount: 0, + latestFailureNodeId: latestFailure.id, + }; + } + + return { + allowed: false, + failedAttemptCount, + newSupportingArtifactCount: 0, + latestFailureNodeId: latestFailure.id, + reason: `Blocked repeated final_report dispatch because ${failedAttemptCount} recent final_report attempt(s) already failed and no new evidence/synthesis artifact was added after the latest failure.`, + }; +} diff --git a/src/lib/deep-research/final-report-runtime.ts b/src/lib/deep-research/final-report-runtime.ts new file mode 100644 index 00000000..92ed3b7e --- /dev/null +++ b/src/lib/deep-research/final-report-runtime.ts @@ -0,0 +1,656 @@ +import { generateText } from "ai"; +import { buildResearchContextArchivePromptBlock } from "./context-archive"; +import { safeParseJson } from "./json-response"; +import { + analyzeFinalReportCitationCoverage, + appendDeterministicReferencesSection, + assembleFinalReportFromSections, + buildFinalReportCitationEntries, + buildFinalReportCoverageRevisionPrompt, + buildFinalReportPromptBundle, + buildFinalReportPlannerSystemPrompt, + buildFinalReportSectionCitationRevisionPrompt, + buildFinalReportSectionDraftPrompt, + buildFinalReportSectionPlanPrompt, + buildFinalReportSystemPrompt, + extractRecognizedCitationKeys, + getFinalReportDraftingOrder, + getRelevantChapterPacketsForSection, + isSurveyLikeResearchRequest, + normalizeFinalReportSectionPlan, +} from "./prompts"; +import { resolveArtifactReferenceIds } from "./artifact-references"; +import type { + ArtifactProvenance, + ArtifactType, + DeepResearchArtifact, + DeepResearchMessage, + DeepResearchNode, + DeepResearchSession, +} from "./types"; +import type { LanguageModel } from "ai"; + +type FinalReportFailureCode = "insufficient_evidence" | "context_overflow" | "draft_failed"; + +export class FinalReportExecutionError extends Error { + readonly code: FinalReportFailureCode; + readonly stage: string; + readonly recommendedAction: string; + readonly details: Record<string, unknown>; + + constructor(input: { + code: FinalReportFailureCode; + stage: string; + message: string; + recommendedAction: string; + details?: Record<string, unknown>; + }) { + super(input.message); + this.name = "FinalReportExecutionError"; + this.code = input.code; + this.stage = input.stage; + this.recommendedAction = input.recommendedAction; + this.details = input.details ?? {}; + } +} + +export function isFinalReportExecutionError(error: unknown): error is FinalReportExecutionError { + return error instanceof FinalReportExecutionError; +} + +export interface FinalReportExecutionContext { + session: DeepResearchSession; + messages: DeepResearchMessage[]; + allArtifacts: DeepResearchArtifact[]; +} + +export async function executeFinalReportNode( + node: DeepResearchNode, + ctx: FinalReportExecutionContext, + model: LanguageModel, + abortSignal?: AbortSignal, +): Promise<{ + output: Record<string, unknown>; + artifacts: Array<{ + artifactType: ArtifactType; + title: string; + content: Record<string, unknown>; + provenance: ArtifactProvenance | null; + }>; + tokensUsed: number; +}> { + const userMessages = ctx.messages + .filter((message) => message.role === "user") + .map((message) => message.content); + const shouldRespondInChinese = /[\u4e00-\u9fff]/.test(ctx.session.title) + || userMessages.some((message) => /[\u4e00-\u9fff]/.test(message)); + const preferredOutputLanguage = shouldRespondInChinese ? "zh" : "en"; + const isSurveyLikeRequest = isSurveyLikeResearchRequest(ctx.session.title, userMessages); + const reportSystemPrompt = buildFinalReportSystemPrompt(node); + const citationEntries = buildFinalReportCitationEntries(ctx.allArtifacts); + const archiveQuery = `final_report ${ctx.session.title} ${node.label} ${userMessages.slice(-2).join(" ")}`.trim(); + const preferredArtifactIds = resolveArtifactReferenceIds(node.input, ctx.allArtifacts); + + const runDraftPipeline = async ( + digestMode: "standard" | "compact", + ): Promise<{ + artifactContent: Record<string, unknown>; + tokensUsed: number; + }> => { + const promptBundle = buildFinalReportPromptBundle( + ctx.session, + ctx.messages, + ctx.allArtifacts, + { digestMode, preferredArtifactIds }, + ); + const archiveContext = await buildResearchContextArchivePromptBlock({ + session: ctx.session, + messages: ctx.messages, + artifacts: ctx.allArtifacts, + query: archiveQuery, + topK: digestMode === "compact" ? 6 : 4, + maxChars: digestMode === "compact" ? 2800 : 1800, + }); + const artifactDigest = digestMode === "compact" && archiveContext + ? archiveContext + : promptBundle.artifactDigest; + if (!promptBundle.readiness.canDraft) { + throw new FinalReportExecutionError({ + code: "insufficient_evidence", + stage: "preflight", + message: `Final report blocked before drafting. ${promptBundle.readiness.summary}`, + recommendedAction: promptBundle.readiness.recommendedAction, + details: { + digestMode, + readiness: promptBundle.readiness, + }, + }); + } + + const sectionPlanPrompt = buildFinalReportSectionPlanPrompt( + ctx.session, + ctx.messages, + ctx.allArtifacts, + node, + { + digestMode, + artifactDigestOverride: artifactDigest, + preferredArtifactIds, + }, + ); + + let tokensUsed = 0; + let sectionPlanResult; + try { + sectionPlanResult = await generateText({ + model, + system: buildFinalReportPlannerSystemPrompt(node), + messages: [{ role: "user", content: sectionPlanPrompt }], + abortSignal, + }); + } catch (error) { + throw buildFinalReportStageError({ + error, + stage: "planning", + digestMode, + readiness: promptBundle.readiness, + }); + } + + tokensUsed += sectionPlanResult.usage?.totalTokens ?? 0; + const rawSectionPlan = safeParseJson(sectionPlanResult.text); + const sectionPlan = normalizeFinalReportSectionPlan({ + rawPlan: rawSectionPlan, + sessionTitle: ctx.session.title, + preferredOutputLanguage, + isSurveyLikeRequest, + maxBodySections: digestMode === "compact" ? 3 : 4, + }); + const draftingOrder = getFinalReportDraftingOrder(sectionPlan); + const sectionDrafts = new Map<string, string>(); + const fallbackReferenceKeys = new Set<string>(); + const bodySections = draftingOrder.filter((section) => section.kind === "body"); + const draftConcurrency = digestMode === "compact" + ? 1 + : Math.max(1, Math.min(ctx.session.config.maxWorkerConcurrency || 1, 2)); + const referenceExcerptLimit = digestMode === "compact" ? 700 : 1200; + + const bodyDraftResults = await mapWithConcurrency(bodySections, draftConcurrency, async (section) => { + const sectionPackets = getRelevantChapterPacketsForSection({ + section, + artifacts: ctx.allArtifacts, + limit: 2, + }); + for (const packet of sectionPackets) { + for (const citationKey of packet.citationKeys) { + fallbackReferenceKeys.add(citationKey); + } + } + const prompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: ctx.session.title, + preferredOutputLanguage, + section, + sectionPlan, + artifactDigest, + citationRegistry: promptBundle.citationRegistry, + sectionPackets, + referenceExcerptLimit, + }); + + let result; + try { + result = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ role: "user", content: prompt }], + abortSignal, + }); + } catch (error) { + throw buildFinalReportStageError({ + error, + stage: `body:${section.title}`, + digestMode, + readiness: promptBundle.readiness, + }); + } + + let content = result.text.trim(); + if (!content) { + throw new FinalReportExecutionError({ + code: "draft_failed", + stage: `body:${section.title}`, + message: `Final report body draft returned empty content for section "${section.title}". ${promptBundle.readiness.summary}`, + recommendedAction: "Retry with a narrower section scope or add an intermediate structured summary for this topic before regenerating the final report.", + details: { + digestMode, + sectionId: section.id, + sectionTitle: section.title, + readiness: promptBundle.readiness, + }, + }); + } + + const citedKeys = extractRecognizedCitationKeys(content, citationEntries); + const allowedCitationKeys = [...new Set(sectionPackets.flatMap((packet) => packet.citationKeys))]; + if (citedKeys.length === 0 && allowedCitationKeys.length > 0) { + try { + const citationRevisionResult = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ + role: "user", + content: buildFinalReportSectionCitationRevisionPrompt({ + sectionTitle: section.title, + preferredOutputLanguage, + existingSection: content, + relevantPackets: sectionPackets, + allowedCitationKeys, + }), + }], + abortSignal, + }); + const revisedContent = citationRevisionResult.text.trim(); + if (revisedContent) { + content = revisedContent; + } + return { + section, + content, + tokensUsed: (result.usage?.totalTokens ?? 0) + (citationRevisionResult.usage?.totalTokens ?? 0), + }; + } catch { + // Keep the original section draft if the citation rescue pass fails. + } + } + + return { + section, + content, + tokensUsed: result.usage?.totalTokens ?? 0, + }; + }); + + for (const draft of bodyDraftResults) { + tokensUsed += draft.tokensUsed; + sectionDrafts.set(draft.section.id, draft.content); + } + + const introductionSection = draftingOrder.find((section) => section.kind === "introduction"); + if (introductionSection) { + const introductionPackets = getRelevantChapterPacketsForSection({ + section: introductionSection, + artifacts: ctx.allArtifacts, + limit: 2, + }); + for (const packet of introductionPackets) { + for (const citationKey of packet.citationKeys) { + fallbackReferenceKeys.add(citationKey); + } + } + const introductionPrompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: ctx.session.title, + preferredOutputLanguage, + section: introductionSection, + sectionPlan, + artifactDigest, + citationRegistry: promptBundle.citationRegistry, + sectionPackets: introductionPackets, + draftedBodySections: bodySections.map((section) => ({ + title: section.title, + content: sectionDrafts.get(section.id) ?? "", + })), + referenceExcerptLimit, + }); + + let introductionResult; + try { + introductionResult = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ role: "user", content: introductionPrompt }], + abortSignal, + }); + } catch (error) { + throw buildFinalReportStageError({ + error, + stage: "introduction", + digestMode, + readiness: promptBundle.readiness, + }); + } + + let introductionContent = introductionResult.text.trim(); + let introductionTokens = introductionResult.usage?.totalTokens ?? 0; + const introCitedKeys = extractRecognizedCitationKeys(introductionContent, citationEntries); + const introAllowedCitationKeys = [...new Set(introductionPackets.flatMap((packet) => packet.citationKeys))]; + if (introCitedKeys.length === 0 && introAllowedCitationKeys.length > 0) { + try { + const introductionCitationRevision = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ + role: "user", + content: buildFinalReportSectionCitationRevisionPrompt({ + sectionTitle: introductionSection.title, + preferredOutputLanguage, + existingSection: introductionContent, + relevantPackets: introductionPackets, + allowedCitationKeys: introAllowedCitationKeys, + }), + }], + abortSignal, + }); + const revisedIntroduction = introductionCitationRevision.text.trim(); + if (revisedIntroduction) { + introductionContent = revisedIntroduction; + } + introductionTokens += introductionCitationRevision.usage?.totalTokens ?? 0; + } catch { + // Preserve the original introduction draft on rescue failure. + } + } + + tokensUsed += introductionTokens; + sectionDrafts.set(introductionSection.id, introductionContent); + } + + const conclusionSection = draftingOrder.find((section) => section.kind === "conclusion"); + if (conclusionSection) { + const conclusionPackets = getRelevantChapterPacketsForSection({ + section: conclusionSection, + artifacts: ctx.allArtifacts, + limit: 3, + }); + for (const packet of conclusionPackets) { + for (const citationKey of packet.citationKeys) { + fallbackReferenceKeys.add(citationKey); + } + } + const draftedFullSections = sectionPlan.sections + .filter((section) => section.kind !== "conclusion") + .map((section) => ({ + title: section.title, + content: sectionDrafts.get(section.id) ?? "", + })); + const conclusionPrompt = buildFinalReportSectionDraftPrompt({ + sessionTitle: ctx.session.title, + preferredOutputLanguage, + section: conclusionSection, + sectionPlan, + artifactDigest, + citationRegistry: promptBundle.citationRegistry, + sectionPackets: conclusionPackets, + draftedFullSections, + referenceExcerptLimit, + }); + + let conclusionResult; + try { + conclusionResult = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ role: "user", content: conclusionPrompt }], + abortSignal, + }); + } catch (error) { + throw buildFinalReportStageError({ + error, + stage: "conclusion", + digestMode, + readiness: promptBundle.readiness, + }); + } + + let conclusionContent = conclusionResult.text.trim(); + let conclusionTokens = conclusionResult.usage?.totalTokens ?? 0; + const conclusionCitedKeys = extractRecognizedCitationKeys(conclusionContent, citationEntries); + const conclusionAllowedCitationKeys = [...new Set(conclusionPackets.flatMap((packet) => packet.citationKeys))]; + if (conclusionCitedKeys.length === 0 && conclusionAllowedCitationKeys.length > 0) { + try { + const conclusionCitationRevision = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ + role: "user", + content: buildFinalReportSectionCitationRevisionPrompt({ + sectionTitle: conclusionSection.title, + preferredOutputLanguage, + existingSection: conclusionContent, + relevantPackets: conclusionPackets, + allowedCitationKeys: conclusionAllowedCitationKeys, + }), + }], + abortSignal, + }); + const revisedConclusion = conclusionCitationRevision.text.trim(); + if (revisedConclusion) { + conclusionContent = revisedConclusion; + } + conclusionTokens += conclusionCitationRevision.usage?.totalTokens ?? 0; + } catch { + // Preserve the original conclusion draft on rescue failure. + } + } + + tokensUsed += conclusionTokens; + sectionDrafts.set(conclusionSection.id, conclusionContent); + } + + let output: Record<string, unknown> = {}; + let reportText = assembleFinalReportFromSections({ + reportTitle: sectionPlan.reportTitle, + sectionPlan, + sectionDrafts, + }); + const deterministicReferences = appendDeterministicReferencesSection({ + reportText, + citationEntries, + preferredOutputLanguage, + fallbackCitationKeys: [...fallbackReferenceKeys], + minimumReferenceCount: citationEntries.length > 0 + ? Math.min(citationEntries.length, isSurveyLikeRequest ? 12 : 8) + : undefined, + }); + reportText = deterministicReferences.reportText; + let revisedForCoverage = false; + let coverageRevisionSkippedReason: string | null = null; + + const initialCoverage = analyzeFinalReportCitationCoverage( + reportText, + ctx.allArtifacts, + ctx.session.title, + userMessages, + ); + + if ( + citationEntries.length > 0 && + !initialCoverage.meetsCoverage && + initialCoverage.minimumRequiredCitationCount > 0 + ) { + const revisionInputLimit = digestMode === "compact" ? 18_000 : 28_000; + if (reportText.length > revisionInputLimit) { + coverageRevisionSkippedReason = `Skipped citation-coverage revision because the assembled report is ${reportText.length} chars, above the ${revisionInputLimit}-char safety limit for an extra rewrite pass.`; + } else { + const revisionPrompt = buildFinalReportCoverageRevisionPrompt({ + sessionTitle: ctx.session.title, + preferredOutputLanguage, + existingReport: reportText, + coverage: initialCoverage, + citationEntries, + }); + + let revisionResult; + try { + revisionResult = await generateText({ + model, + system: reportSystemPrompt, + messages: [{ role: "user", content: revisionPrompt }], + abortSignal, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + coverageRevisionSkippedReason = `Coverage revision failed; preserved the assembled report instead. Provider error: ${message}`; + revisionResult = null; + } + + if (revisionResult) { + tokensUsed += revisionResult.usage?.totalTokens ?? 0; + + const revisedOutput = safeParseJson(revisionResult.text); + const revisedReportText = (revisedOutput.report as string) + || (revisedOutput.messageToUser as string) + || (revisedOutput.text as string) + || revisionResult.text; + const revisedCoverage = analyzeFinalReportCitationCoverage( + revisedReportText, + ctx.allArtifacts, + ctx.session.title, + userMessages, + ); + + if (revisedCoverage.citedCitationCount >= initialCoverage.citedCitationCount) { + output = revisedOutput; + reportText = revisedReportText; + revisedForCoverage = true; + } + } + } + } + + const finalCoverage = analyzeFinalReportCitationCoverage( + reportText, + ctx.allArtifacts, + ctx.session.title, + userMessages, + ); + + return { + artifactContent: { + report: reportText.trim(), + sectionPlan, + draftingOrder: draftingOrder.map((section) => ({ + id: section.id, + title: section.title, + kind: section.kind, + })), + citationCoverage: finalCoverage, + revisedForCoverage, + readiness: promptBundle.readiness, + generationStrategy: { + digestMode, + draftConcurrency, + bodySectionCount: bodySections.length, + referenceExcerptLimit, + deterministicReferencesAdded: deterministicReferences.referencesAdded, + deterministicReferenceKeyCount: deterministicReferences.citedCitationKeys.length, + usedPersistedContextArchive: Boolean(archiveContext), + coverageRevisionSkippedReason, + }, + ...output, + }, + tokensUsed, + }; + }; + + let draftResult: { + artifactContent: Record<string, unknown>; + tokensUsed: number; + }; + let compactFallbackUsed = false; + + try { + draftResult = await runDraftPipeline("standard"); + } catch (error) { + if (!shouldRetryFinalReportInCompactMode(error)) { + throw error; + } + compactFallbackUsed = true; + draftResult = await runDraftPipeline("compact"); + } + + const artifactContent = { + ...draftResult.artifactContent, + compactFallbackUsed, + }; + + return { + output: artifactContent, + artifacts: [{ + artifactType: "final_report" as ArtifactType, + title: node.label, + content: artifactContent, + provenance: { + sourceNodeId: node.id, + sourceArtifactIds: ctx.allArtifacts.map((artifact) => artifact.id), + model: node.assignedModel || "unknown", + generatedAt: new Date().toISOString(), + } as ArtifactProvenance, + }], + tokensUsed: draftResult.tokensUsed, + }; +} + +function buildFinalReportStageError(input: { + error: unknown; + stage: string; + digestMode: "standard" | "compact"; + readiness: { + summary: string; + recommendedAction: string; + }; +}): FinalReportExecutionError { + const message = input.error instanceof Error ? input.error.message : String(input.error); + const code: FinalReportFailureCode = /context|token|too long|maximum context|input.*large|prompt.*large|length/i.test(message) + ? "context_overflow" + : "draft_failed"; + + return new FinalReportExecutionError({ + code, + stage: input.stage, + message: `Final report ${input.stage} failed in ${input.digestMode} mode. ${input.readiness.summary ?? "Readiness summary unavailable."} Provider error: ${message}`, + recommendedAction: code === "context_overflow" + ? "Retry with a more compact synthesis context, fewer body sections, or add an intermediate structured summary before another final-report attempt." + : input.readiness.recommendedAction ?? "Inspect the upstream synthesis artifacts and retry with a narrower, better-structured final-report task.", + details: { + digestMode: input.digestMode, + readiness: input.readiness, + providerError: message, + }, + }); +} + +function shouldRetryFinalReportInCompactMode(error: unknown): boolean { + if (isFinalReportExecutionError(error)) { + return error.code === "context_overflow"; + } + + const message = error instanceof Error ? error.message : String(error); + return /context|token|too long|maximum context|input.*large|prompt.*large|rate limit|overload|capacity/i.test(message); +} + +async function mapWithConcurrency<T, R>( + items: T[], + concurrency: number, + mapper: (item: T, index: number) => Promise<R>, +): Promise<R[]> { + if (items.length === 0) { + return []; + } + + const results = new Array<R>(items.length); + let cursor = 0; + const workerCount = Math.max(1, Math.min(concurrency, items.length)); + + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= items.length) { + return; + } + results[index] = await mapper(items[index], index); + } + })); + + return results; +} diff --git a/src/lib/deep-research/final-report.test.ts b/src/lib/deep-research/final-report.test.ts index 10c2de03..6e7cd4de 100644 --- a/src/lib/deep-research/final-report.test.ts +++ b/src/lib/deep-research/final-report.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { extractFinalReportText, getLatestFinalReportArtifact } from "./final-report"; +import { + extractFinalReportText, + extractFinalReportTextWithFallbackReferences, + getLatestFinalReportArtifact, +} from "./final-report"; import type { DeepResearchArtifact } from "./types"; function createArtifact( @@ -44,4 +48,32 @@ describe("final-report helpers", () => { expect(extractFinalReportText(artifact)).toBe("Detailed markdown report"); }); + + it("backfills references for old final-report artifacts when evidence exists", () => { + const finalReport = createArtifact({ + content: { + report: "# Report\n\n正文没有 references。", + }, + }); + const evidence = createArtifact({ + id: "artifact-evidence", + artifactType: "evidence_card", + title: "Evidence", + content: { + query: "time series transformer", + sources: [ + { + title: "Informer", + url: "https://arxiv.org/abs/2012.07436", + year: 2021, + }, + ], + }, + }); + + const text = extractFinalReportTextWithFallbackReferences(finalReport, [finalReport, evidence]); + + expect(text).toContain("## 参考文献与来源线索"); + expect(text).toContain("[Informer, 2021]"); + }); }); diff --git a/src/lib/deep-research/final-report.ts b/src/lib/deep-research/final-report.ts index 811717b8..e77fe7b7 100644 --- a/src/lib/deep-research/final-report.ts +++ b/src/lib/deep-research/final-report.ts @@ -1,5 +1,23 @@ +import { + appendDeterministicReferencesSection, + buildFinalReportCitationEntries, +} from "./prompts"; import type { DeepResearchArtifact } from "./types"; +export interface FinalReportCitationCoverageSummary { + availableCitationCount: number; + citedCitationCount: number; + minimumRequiredCitationCount: number; + hasReferencesSection: boolean; + meetsCoverage: boolean; + revisedForCoverage: boolean; +} + +export interface FinalReportPresentation { + reportText: string; + citationCoverage: FinalReportCitationCoverageSummary | null; +} + export function extractFinalReportText(artifact: DeepResearchArtifact): string { const content = artifact.content; const candidates = [ @@ -19,6 +37,25 @@ export function extractFinalReportText(artifact: DeepResearchArtifact): string { return JSON.stringify(content, null, 2); } +export function extractFinalReportTextWithFallbackReferences( + artifact: DeepResearchArtifact, + artifacts: DeepResearchArtifact[], +): string { + const reportText = extractFinalReportText(artifact); + const preferredOutputLanguage = /[\u4e00-\u9fff]/.test(reportText) ? "zh" : "en"; + const citationEntries = buildFinalReportCitationEntries(artifacts); + + if (citationEntries.length === 0) { + return reportText; + } + + return appendDeterministicReferencesSection({ + reportText, + citationEntries, + preferredOutputLanguage, + }).reportText; +} + export function getLatestFinalReportArtifact( artifacts: DeepResearchArtifact[], ): DeepResearchArtifact | null { @@ -36,3 +73,59 @@ export function getLatestFinalReportArtifact( return latest; } + +export function extractFinalReportCitationCoverage( + artifact: DeepResearchArtifact | null, + artifacts?: DeepResearchArtifact[], +): FinalReportCitationCoverageSummary | null { + if (!artifact) { + return null; + } + + const coverage = artifact.content.citationCoverage; + if (!coverage || typeof coverage !== "object") { + return null; + } + + const record = coverage as Record<string, unknown>; + if ( + typeof record.availableCitationCount !== "number" || + typeof record.citedCitationCount !== "number" || + typeof record.minimumRequiredCitationCount !== "number" || + typeof record.hasReferencesSection !== "boolean" || + typeof record.meetsCoverage !== "boolean" + ) { + return null; + } + + const reportText = artifacts + ? extractFinalReportTextWithFallbackReferences(artifact, artifacts) + : extractFinalReportText(artifact); + const hasReferencesSection = /(^|\n)#{1,6}\s*(references|reference trail|参考文献|参考文献与来源线索)\b/i.test(reportText); + + return { + availableCitationCount: record.availableCitationCount, + citedCitationCount: record.citedCitationCount, + minimumRequiredCitationCount: record.minimumRequiredCitationCount, + hasReferencesSection, + meetsCoverage: record.citedCitationCount >= record.minimumRequiredCitationCount && hasReferencesSection, + revisedForCoverage: artifact.content.revisedForCoverage === true, + }; +} + +export function resolveFinalReportPresentation( + artifact: DeepResearchArtifact | null, + artifacts: DeepResearchArtifact[], +): FinalReportPresentation { + if (!artifact) { + return { + reportText: "", + citationCoverage: null, + }; + } + + return { + reportText: extractFinalReportTextWithFallbackReferences(artifact, artifacts), + citationCoverage: extractFinalReportCitationCoverage(artifact, artifacts), + }; +} diff --git a/src/lib/deep-research/language-state.ts b/src/lib/deep-research/language-state.ts new file mode 100644 index 00000000..ccdb4837 --- /dev/null +++ b/src/lib/deep-research/language-state.ts @@ -0,0 +1,29 @@ +import type { LanguageState } from "./types"; + +/** Detect primary language from text using simple heuristics. */ +function detectLanguage(text: string): string { + const cjkChars = text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g); + const totalChars = text.replace(/\s/g, "").length; + if (cjkChars && cjkChars.length > totalChars * 0.1) return "zh"; + + const jpChars = text.match(/[\u3040-\u309f\u30a0-\u30ff]/g); + if (jpChars && jpChars.length > 5) return "ja"; + + const krChars = text.match(/[\uac00-\ud7af]/g); + if (krChars && krChars.length > 5) return "ko"; + + return "en"; +} + +export function resolveLanguageState(messages: { role: string; content: string }[]): LanguageState { + const userMessages = messages.filter((message) => message.role === "user"); + const latestUserMessage = userMessages[userMessages.length - 1]; + const language = latestUserMessage ? detectLanguage(latestUserMessage.content) : "en"; + + return { + currentUserLanguage: language, + preferredOutputLanguage: language, + lastDetectedUserLanguage: language, + lastLanguageUpdateAt: new Date().toISOString(), + }; +} diff --git a/src/lib/deep-research/model-overrides.test.ts b/src/lib/deep-research/model-overrides.test.ts new file mode 100644 index 00000000..0807734e --- /dev/null +++ b/src/lib/deep-research/model-overrides.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +async function importWithEnv(env: Record<string, string>) { + vi.resetModules(); + vi.doMock("@/lib/ai/provider-env", () => ({ + getCurrentEnv: () => env, + })); + return import("./model-overrides"); +} + +describe("deep research model overrides", () => { + it("prefers Claude Opus for final-report packaging when Anthropic is configured", async () => { + const mod = await importWithEnv({ + ANTHROPIC_API_KEY: "test-key", + }); + + const overrides = mod.getDefaultDeepResearchModelOverrides({ + provider: "openai", + modelId: "gpt-4o-mini", + }); + + expect(overrides?.research_asset_reuse_specialist).toEqual({ + provider: "anthropic", + modelId: "claude-opus-4-6", + }); + }); + + it("does not force an override when no preferred long-form provider is configured", async () => { + const mod = await importWithEnv({}); + + const overrides = mod.getDefaultDeepResearchModelOverrides({ + provider: "openai", + modelId: "gpt-4.1", + }); + + expect(overrides).toBeUndefined(); + }); +}); diff --git a/src/lib/deep-research/model-overrides.ts b/src/lib/deep-research/model-overrides.ts new file mode 100644 index 00000000..3b42b147 --- /dev/null +++ b/src/lib/deep-research/model-overrides.ts @@ -0,0 +1,107 @@ +import { getCurrentEnv } from "@/lib/ai/provider-env"; +import { PROVIDERS } from "@/lib/ai/models"; +import type { DeepResearchConfig } from "./types"; + +export type ModelRoute = { provider: string; modelId: string }; + +const LONG_FORM_FINAL_REPORT_PREFERENCES: ModelRoute[] = [ + { provider: "anthropic", modelId: "claude-opus-4-6" }, + { provider: "gemini", modelId: "gemini-2.5-pro" }, + { provider: "openai", modelId: "gpt-4.1" }, + { provider: "moonshot", modelId: "kimi-k2.5" }, + { provider: "qwen", modelId: "Qwen3-235B" }, +]; + +function isConfiguredProvider(providerId: string): boolean { + const env = getCurrentEnv(); + const provider = PROVIDERS[providerId as keyof typeof PROVIDERS]; + if (!provider) { + return false; + } + + return Boolean(env[provider.envKey]); +} + +function isKnownModelRoute(route: ModelRoute): boolean { + const provider = PROVIDERS[route.provider as keyof typeof PROVIDERS]; + if (!provider) { + return false; + } + + return provider.models.some((model) => model.id === route.modelId); +} + +export function getPreferredFinalReportModelRoute( + fallbackRoute?: ModelRoute | null, +): ModelRoute | null { + for (const route of LONG_FORM_FINAL_REPORT_PREFERENCES) { + if (isConfiguredProvider(route.provider) && isKnownModelRoute(route)) { + return route; + } + } + + if (fallbackRoute && isKnownModelRoute(fallbackRoute)) { + return fallbackRoute; + } + + return null; +} + +export function getDefaultDeepResearchModelOverrides( + resolvedModel?: ModelRoute | null, +): DeepResearchConfig["modelOverrides"] | undefined { + const finalReportRoute = getPreferredFinalReportModelRoute(resolvedModel ?? null); + if (!finalReportRoute) { + return undefined; + } + + if ( + resolvedModel + && resolvedModel.provider === finalReportRoute.provider + && resolvedModel.modelId === finalReportRoute.modelId + ) { + return undefined; + } + + return { + research_asset_reuse_specialist: finalReportRoute, + }; +} + +export function buildDeepResearchConfigWithRoleOverrides(input: { + config: DeepResearchConfig; + resolvedModel: ModelRoute; +}): DeepResearchConfig { + const defaultOverrides = getDefaultDeepResearchModelOverrides(input.resolvedModel); + const mergedOverrides = input.config.modelOverrides ?? defaultOverrides; + + return { + ...input.config, + resolvedModel: input.resolvedModel, + modelOverrides: mergedOverrides, + }; +} + +export function buildDeepResearchConfigForResolvedModel( + config: DeepResearchConfig, + resolvedModel: ModelRoute, + overrides?: Partial<DeepResearchConfig>, +): DeepResearchConfig { + return buildDeepResearchConfigWithRoleOverrides({ + config: { + ...config, + ...overrides, + resolvedModel, + }, + resolvedModel, + }); +} + +export function hasDeepResearchModelConfigDrift( + currentConfig: DeepResearchConfig, + nextConfig: DeepResearchConfig, +): boolean { + return currentConfig.resolvedModel?.provider !== nextConfig.resolvedModel?.provider + || currentConfig.resolvedModel?.modelId !== nextConfig.resolvedModel?.modelId + || JSON.stringify(currentConfig.modelOverrides ?? null) !== JSON.stringify(nextConfig.modelOverrides ?? null); +} diff --git a/src/lib/deep-research/model-router.test.ts b/src/lib/deep-research/model-router.test.ts new file mode 100644 index 00000000..3bee097d --- /dev/null +++ b/src/lib/deep-research/model-router.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { getRouteCandidatesForRole } from "./model-router"; +import type { DeepResearchConfig } from "./types"; + +function createConfig(overrides?: Partial<DeepResearchConfig>): DeepResearchConfig { + return { + interfaceOnly: false, + budget: { + maxTotalTokens: 100000, + maxOpusTokens: 50000, + }, + maxWorkerFanOut: 1, + maxReviewerRounds: 2, + maxExecutionLoops: 2, + maxWorkerConcurrency: 1, + literature: { + maxLiteratureRounds: 3, + maxPapersPerRound: 10, + maxTotalPapers: 30, + maxReviewerRequestedExpansionRounds: 1, + maxSearchRetries: 2, + }, + execution: { + defaultLauncherType: "local_shell", + defaultResources: { gpu: 0, memoryMb: 0, cpu: 1, privateMachine: "no" }, + defaultMounts: [], + defaultChargedGroup: "", + }, + resolvedModel: { + provider: "openai", + modelId: "gpt-4o-mini", + }, + ...overrides, + }; +} + +describe("model router", () => { + it("prioritizes role overrides ahead of the resolved session model", () => { + const routes = getRouteCandidatesForRole( + "research_asset_reuse_specialist", + createConfig({ + modelOverrides: { + research_asset_reuse_specialist: { + provider: "anthropic", + modelId: "claude-opus-4-6", + }, + }, + }), + ); + + expect(routes[0]).toEqual({ + provider: "anthropic", + modelId: "claude-opus-4-6", + }); + expect(routes).toContainEqual({ + provider: "openai", + modelId: "gpt-4o-mini", + }); + }); + + it("deduplicates identical override and resolved routes", () => { + const routes = getRouteCandidatesForRole( + "research_asset_reuse_specialist", + createConfig({ + modelOverrides: { + research_asset_reuse_specialist: { + provider: "openai", + modelId: "gpt-4o-mini", + }, + }, + }), + ); + + expect(routes.filter((route) => route.provider === "openai" && route.modelId === "gpt-4o-mini")).toHaveLength(1); + }); +}); diff --git a/src/lib/deep-research/model-router.ts b/src/lib/deep-research/model-router.ts index b2932f2b..a419fdd8 100644 --- a/src/lib/deep-research/model-router.ts +++ b/src/lib/deep-research/model-router.ts @@ -12,20 +12,43 @@ interface ModelRoute { modelId: string; } -function getConfiguredRoute(config?: DeepResearchConfig): ModelRoute { +export function getRouteCandidatesForRole( + role: ModelRole, + config?: DeepResearchConfig, +): ModelRoute[] { + const candidates: ModelRoute[] = []; + const seen = new Set<string>(); + const push = (route: ModelRoute | null | undefined) => { + if (!route?.provider || !route?.modelId) { + return; + } + const key = `${route.provider}::${route.modelId}`; + if (seen.has(key)) { + return; + } + seen.add(key); + candidates.push(route); + }; + + const override = config?.modelOverrides?.[role]; + if (override?.provider && override?.modelId) { + push({ provider: override.provider, modelId: override.modelId }); + } + const resolved = config?.resolvedModel; if (resolved?.provider && resolved?.modelId) { - return { provider: resolved.provider, modelId: resolved.modelId }; + push({ provider: resolved.provider, modelId: resolved.modelId }); } - const { providerId, modelId } = getConfiguredModelSelectionFromEnv(); - return { provider: providerId, modelId }; + const envConfigured = getConfiguredModelSelectionFromEnv(); + push({ provider: envConfigured.providerId, modelId: envConfigured.modelId }); + + return candidates; } function resolveConfiguredModel( - config?: DeepResearchConfig, + route: ModelRoute, ): { model: LanguageModel; provider: string; modelId: string } { - const route = getConfiguredRoute(config); try { const { model } = getModelFromOverride(route.provider, route.modelId); return { model, provider: route.provider, modelId: route.modelId }; @@ -45,8 +68,18 @@ export function getModelForRole( role: ModelRole, config?: DeepResearchConfig ): { model: LanguageModel; provider: string; modelId: string } { - void role; - return resolveConfiguredModel(config); + const chain = getModelChainForRole(role, config); + if (chain.length === 0) { + const candidates = getRouteCandidatesForRole(role, config); + const last = candidates[candidates.length - 1]; + throw new Error( + last + ? `Deep Research could not initialize any model for role "${role}". Last attempted route: ${last.provider}/${last.modelId}` + : `Deep Research could not initialize any model for role "${role}". No route candidates were available.`, + ); + } + + return chain[0]; } /** @@ -57,8 +90,16 @@ export function getModelChainForRole( role: ModelRole, config?: DeepResearchConfig ): Array<{ model: LanguageModel; provider: string; modelId: string }> { - void role; - return [resolveConfiguredModel(config)]; + const resolved: Array<{ model: LanguageModel; provider: string; modelId: string }> = []; + for (const route of getRouteCandidatesForRole(role, config)) { + try { + resolved.push(resolveConfiguredModel(route)); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn(`[model-router] Failed to initialize route ${route.provider}/${route.modelId} for role ${role}: ${detail}`); + } + } + return resolved; } // --- Budget tracking --- diff --git a/src/lib/deep-research/node-executor.ts b/src/lib/deep-research/node-executor.ts index 05264c7d..b9947ac2 100644 --- a/src/lib/deep-research/node-executor.ts +++ b/src/lib/deep-research/node-executor.ts @@ -14,8 +14,19 @@ import { } from "@/lib/article-search"; import { buildEvidenceCardFromToolResults } from "./evidence-cards"; import { safeParseJson } from "./json-response"; +import { buildResearchContextArchivePromptBlock } from "./context-archive"; import { buildResearchMemoryPromptBlock } from "./memory-fabric"; import { buildResearcherDoctrinePromptBlock } from "./researcher-doctrine"; +import { resolveArtifactReferenceIds } from "./artifact-references"; +import { + executeFinalReportNode, + FinalReportExecutionError, + isFinalReportExecutionError, +} from "./final-report-runtime"; +import { + buildClaimMapFromStructuredSummary, + normalizeStructuredSummaryArtifact, +} from "./summary-packets"; import type { DeepResearchNode, DeepResearchArtifact, @@ -38,6 +49,11 @@ interface ExecutionResult { tokensUsed: number; } +export { + FinalReportExecutionError, + isFinalReportExecutionError, +}; + /** * Execute a single node: resolve model, build prompt, call LLM, persist results. */ @@ -150,18 +166,22 @@ async function executeByNodeType( }>; tokensUsed: number; }> { - const parentArtifacts = ctx.allArtifacts.filter( + const dependencyArtifacts = ctx.allArtifacts.filter( (a) => a.nodeId && node.dependsOn.includes(a.nodeId) ); + const referencedArtifacts = resolveReferencedArtifacts(node.input, ctx.allArtifacts); + const parentArtifacts = mergeArtifactsById(dependencyArtifacts, referencedArtifacts); switch (node.nodeType) { // Main brain nodes case "intake": case "plan": case "synthesize": - case "final_report": return executeBrainNode(node, ctx, model, abortSignal); + case "final_report": + return executeFinalReportNode(node, ctx, model, abortSignal); + // Worker evidence nodes case "evidence_gather": return executeEvidenceGather(node, parentArtifacts, model, abortSignal); @@ -232,10 +252,21 @@ async function executeBrainNode( artifacts: ctx.allArtifacts, query: `${node.nodeType} ${node.label}`.trim(), }); + const archiveContext = await buildResearchContextArchivePromptBlock({ + session: ctx.session, + messages: ctx.messages, + artifacts: ctx.allArtifacts, + query: `${node.nodeType} ${node.label}`.trim(), + topK: 5, + maxChars: 2200, + }); const doctrineContext = await buildResearcherDoctrinePromptBlock({ contextTag: ctx.session.contextTag, query: `${node.nodeType} ${node.label}`.trim(), }); + const combinedMemoryContext = [memoryContext, archiveContext] + .filter((block): block is string => typeof block === "string" && block.length > 0) + .join("\n\n"); const systemPrompt = buildMainBrainSystemPrompt( ctx.session, ctx.messages, @@ -244,7 +275,7 @@ async function executeBrainNode( ctx.session.contextTag, undefined, undefined, - memoryContext, + combinedMemoryContext || null, doctrineContext, ); @@ -402,14 +433,30 @@ async function executeSummarize( abortSignal, }); - const output = { summary: result.text }; + const rawOutput = safeParseJson(result.text); + const output = normalizeStructuredSummaryArtifact({ + rawOutput, + parentArtifacts, + label: node.label, + }); + const claimMap = buildClaimMapFromStructuredSummary(output, parentArtifacts); return { - output, + output: output as unknown as Record<string, unknown>, artifacts: [{ artifactType: "structured_summary" as ArtifactType, title: `Summary: ${node.label}`, - content: output, + content: output as unknown as Record<string, unknown>, + provenance: { + sourceNodeId: node.id, + sourceArtifactIds: parentArtifacts.map((a) => a.id), + model: node.assignedModel || "unknown", + generatedAt: new Date().toISOString(), + } as ArtifactProvenance, + }, { + artifactType: "claim_map" as ArtifactType, + title: `Claim Map: ${node.label}`, + content: claimMap as unknown as Record<string, unknown>, provenance: { sourceNodeId: node.id, sourceArtifactIds: parentArtifacts.map((a) => a.id), @@ -599,6 +646,36 @@ function buildFallbackKeywords(query: string, focusAreas: string[] | undefined): return [...new Set([...focusTokens, ...queryTokens])].slice(0, 4); } +function resolveReferencedArtifacts( + input: Record<string, unknown> | null | undefined, + allArtifacts: DeepResearchArtifact[], +): DeepResearchArtifact[] { + const resolvedIds = new Set(resolveArtifactReferenceIds(input, allArtifacts)); + if (resolvedIds.size === 0) { + return []; + } + + return allArtifacts.filter((artifact) => resolvedIds.has(artifact.id)); +} + +function mergeArtifactsById( + primaryArtifacts: DeepResearchArtifact[], + secondaryArtifacts: DeepResearchArtifact[], +): DeepResearchArtifact[] { + const merged: DeepResearchArtifact[] = []; + const seen = new Set<string>(); + + for (const artifact of [...primaryArtifacts, ...secondaryArtifacts]) { + if (seen.has(artifact.id)) { + continue; + } + seen.add(artifact.id); + merged.push(artifact); + } + + return merged; +} + function extractSearchQueries( toolCalls: Array<{ toolName?: string; input?: unknown }>, query: string, diff --git a/src/lib/deep-research/orchestrator-checkpoint.ts b/src/lib/deep-research/orchestrator-checkpoint.ts new file mode 100644 index 00000000..a0bb93cf --- /dev/null +++ b/src/lib/deep-research/orchestrator-checkpoint.ts @@ -0,0 +1,278 @@ +import { generateText } from "ai"; +import { nanoid } from "nanoid"; +import { getModelForRole, checkBudget, trackUsage } from "./model-router"; +import * as store from "./event-store"; +import { buildCheckpointPrompt } from "./prompts"; +import { normalizeNodeCreationSpecs } from "./node-spec-normalizer"; +import { consolidateResearchMemory } from "./memory-fabric"; +import { buildResearcherDoctrinePromptBlock } from "./researcher-doctrine"; +import { + applyFinalReportCheckpointGuard, + getCheckpointReviewArtifacts, + getEvidencePhaseSummary, + getFinalReportCheckpointCopy, + getRecommendedDispatch, +} from "./checkpoint-runtime"; +import { canCompleteSession } from "./session-guards"; +import { safeParseJson } from "./json-response"; +import type { + BrainDecision, + CheckpointInteractionMode, + CheckpointPackage, + ContextTag, + DeepResearchArtifact, + DeepResearchNode, + DeepResearchSession, + LanguageState, + MainBrainAudit, + NodeCreationSpec, + ReviewAssessment, +} from "./types"; + +export function resolveCheckpointInteractionMode( + decision: BrainDecision, + plannedNodesToCreate: NodeCreationSpec[], +): CheckpointInteractionMode { + if (decision.action === "respond_to_user" && plannedNodesToCreate.length === 0) { + return "answer_required"; + } + return "confirmation"; +} + +export async function generateCheckpointAndHalt(input: { + session: DeepResearchSession; + completedNode: DeepResearchNode; + suggestedNextContextTag: ContextTag; + languageState: LanguageState; + abortSignal?: AbortSignal; + isFinalStep?: boolean; + interactionMode?: CheckpointInteractionMode; +}): Promise<void> { + const { + session, + completedNode, + suggestedNextContextTag, + languageState, + abortSignal, + } = input; + let isFinalStep = input.isFinalStep ?? false; + const interactionMode = input.interactionMode ?? "confirmation"; + + const freshNodes = await store.getNodes(session.id); + const freshNode = freshNodes.find((node) => node.id === completedNode.id) ?? completedNode; + const checkpointContextTag = freshNode.contextTag ?? session.contextTag; + const artifacts = await store.getArtifacts(session.id); + const isFinalReportingCheckpoint = isFinalStep || freshNode.nodeType === "final_report" || checkpointContextTag === "final_report"; + const finalReportCheckpointCopy = isFinalReportingCheckpoint + ? getFinalReportCheckpointCopy(languageState.preferredOutputLanguage) + : null; + const checkpointReviewArtifacts = getCheckpointReviewArtifacts(checkpointContextTag, freshNode, freshNodes, artifacts); + const literatureSummary = getEvidencePhaseSummary(checkpointContextTag, freshNodes, checkpointReviewArtifacts); + const planArtifact = artifacts.find((artifact) => + artifact.nodeId === freshNode.id && artifact.artifactType === "task_graph" + ); + const plannedSpecs = planArtifact && Array.isArray(planArtifact.content.proposedNodeSpecs) + ? normalizeNodeCreationSpecs(planArtifact.content.proposedNodeSpecs as unknown[], checkpointContextTag).validSpecs + : []; + const plannedNodeCount = typeof planArtifact?.content.nextTaskCount === "number" + ? planArtifact.content.nextTaskCount + : typeof planArtifact?.content.totalNodes === "number" + ? planArtifact.content.totalNodes + : 0; + const recommendedDispatch = getRecommendedDispatch(freshNodes, plannedSpecs); + + const transitionAction = { + nextContextTag: suggestedNextContextTag, + nodesToCreate: plannedSpecs, + nodesToSupersede: [], + description: isFinalReportingCheckpoint && finalReportCheckpointCopy + ? finalReportCheckpointCopy.continueWillDo + : interactionMode === "answer_required" + ? "Wait for the user to answer the Researcher's clarification questions in chat before any further work." + : plannedNodeCount > 0 + ? `If you confirm this next task, the Researcher will authorize it and continue coordination from ${suggestedNextContextTag}.` + : `Resume the session and let the Researcher choose the next work dynamically. Current recommendation: ${suggestedNextContextTag}.`, + }; + + if (isFinalStep) { + const completionCheck = canCompleteSession(freshNodes); + if (!completionCheck.allowed) { + console.warn("[deep-research] isFinalStep=true but completion blocked:", completionCheck.reason); + isFinalStep = false; + } + } + + const langInstruction = languageState.preferredOutputLanguage !== "en" + ? `\n\nIMPORTANT: The user communicates in ${languageState.preferredOutputLanguage}. Write all user-facing text (title, humanSummary, recommendedNextAction, continueWillDo) in ${languageState.preferredOutputLanguage}. Technical terms may remain in English.` + : ""; + + const checkpointContent = await generateCheckpointContent({ + session, + completedNode: freshNode, + artifacts, + nodes: freshNodes, + contextTag: suggestedNextContextTag, + langInstruction, + abortSignal, + }); + const guardedCheckpointContent = isFinalReportingCheckpoint + ? applyFinalReportCheckpointGuard(checkpointContent, languageState.preferredOutputLanguage) + : checkpointContent; + + const checkpointPkg: CheckpointPackage = { + checkpointId: nanoid(), + sessionId: session.id, + nodeId: freshNode.id, + stepType: freshNode.nodeType, + contextTag: checkpointContextTag, + title: guardedCheckpointContent.title || `${freshNode.label} completed`, + humanSummary: guardedCheckpointContent.humanSummary || `Completed: ${freshNode.label}`, + machineSummary: guardedCheckpointContent.machineSummary || "", + mainBrainAudit: guardedCheckpointContent.mainBrainAudit || { + whatWasCompleted: freshNode.label, + resultAssessment: "acceptable", + issuesAndRisks: [], + recommendedNextAction: isFinalReportingCheckpoint && finalReportCheckpointCopy + ? finalReportCheckpointCopy.recommendedNextAction + : recommendedDispatch + ? `Proceed to ${recommendedDispatch.roleName}: ${recommendedDispatch.label}` + : `Proceed to ${suggestedNextContextTag}`, + continueWillDo: transitionAction.description, + alternativeActions: [ + { label: "Revise", description: "Revise current step", actionType: "revise" }, + { label: "Stop", description: "End research", actionType: "stop" }, + ], + canProceed: true, + }, + artifactsToReview: checkpointReviewArtifacts.map((artifact) => artifact.id), + currentFindings: guardedCheckpointContent.currentFindings || "", + openQuestions: guardedCheckpointContent.openQuestions || [], + recommendedNextAction: guardedCheckpointContent.recommendedNextAction || ( + isFinalReportingCheckpoint && finalReportCheckpointCopy + ? finalReportCheckpointCopy.recommendedNextAction + : recommendedDispatch + ? `Proceed to ${recommendedDispatch.roleName}: ${recommendedDispatch.label}` + : `Proceed to ${suggestedNextContextTag}` + ), + recommendedWorker: recommendedDispatch + ? { + roleId: recommendedDispatch.roleId, + roleName: recommendedDispatch.roleName, + nodeType: recommendedDispatch.nodeType, + label: recommendedDispatch.label, + } + : undefined, + promptUsed: recommendedDispatch?.promptUsed, + continueWillDo: transitionAction.description, + alternativeNextActions: guardedCheckpointContent.alternativeNextActions || [], + requiresUserConfirmation: true, + interactionMode, + isFinalStep, + transitionAction, + literatureRoundInfo: !isFinalReportingCheckpoint && session.literatureRound > 0 && literatureSummary ? { + roundNumber: session.literatureRound, + papersCollected: literatureSummary.papersCollected, + retrievalTaskCount: literatureSummary.retrievalTaskCount, + successfulTaskCount: literatureSummary.successfulTaskCount, + failedTaskCount: literatureSummary.failedTaskCount, + emptyTaskCount: literatureSummary.emptyTaskCount, + coverageSummary: guardedCheckpointContent.currentFindings || "", + } : undefined, + reviewInfo: isFinalReportingCheckpoint ? undefined : await getLatestReviewAssessment(session.id), + createdAt: new Date().toISOString(), + }; + + const checkpointArtifact = await store.createCheckpoint(session.id, freshNode.id, checkpointPkg); + await consolidateResearchMemory(session.id, { triggerNodeId: freshNode.id }); + + await store.updateSession(session.id, { + status: "awaiting_user_confirmation", + contextTag: suggestedNextContextTag, + pendingCheckpointId: checkpointArtifact.id, + }); + + const audit = checkpointPkg.mainBrainAudit; + const auditSuffix = audit + ? interactionMode === "answer_required" + ? `\n\n**Assessment:** ${audit.resultAssessment}\n**Recommended:** ${audit.recommendedNextAction}\n**Reply required:** Answer the Researcher's clarification questions in chat before any task will continue.` + : `\n\n**Assessment:** ${audit.resultAssessment}\n**Recommended:** ${audit.recommendedNextAction}\n**"Continue" will:** ${checkpointPkg.continueWillDo}` + : ""; + await store.addMessage( + session.id, + "main_brain", + `**${checkpointPkg.title}**\n\n${checkpointPkg.humanSummary}${auditSuffix}`, + { checkpointId: checkpointArtifact.id }, + freshNode.id, + checkpointPkg.artifactsToReview, + ); +} + +async function generateCheckpointContent(input: { + session: DeepResearchSession; + completedNode: DeepResearchNode; + artifacts: DeepResearchArtifact[]; + nodes: DeepResearchNode[]; + contextTag: ContextTag; + langInstruction: string; + abortSignal?: AbortSignal; +}): Promise<{ + title?: string; + humanSummary?: string; + machineSummary?: string; + mainBrainAudit?: MainBrainAudit; + currentFindings?: string; + openQuestions?: string[]; + recommendedNextAction?: string; + continueWillDo?: string; + alternativeNextActions?: string[]; +}> { + const { session, completedNode, artifacts, nodes, contextTag, langInstruction, abortSignal } = input; + const { model } = getModelForRole("main_brain", session.config); + const budgetCheck = checkBudget("main_brain", session.budget, session.config.budget); + if (!budgetCheck.allowed) { + return { + title: "Budget limit reached", + humanSummary: `Step "${completedNode.label}" completed but checkpoint generation budget exceeded.`, + mainBrainAudit: { + whatWasCompleted: completedNode.label, + resultAssessment: "acceptable", + issuesAndRisks: ["Budget limit reached"], + recommendedNextAction: "Review manually and decide", + continueWillDo: `Advance to ${contextTag}`, + alternativeActions: [], + canProceed: true, + }, + }; + } + + try { + const prompt = buildCheckpointPrompt(session, completedNode, artifacts, nodes, contextTag); + const doctrineContext = await buildResearcherDoctrinePromptBlock({ + contextTag, + query: `${completedNode.nodeType} ${completedNode.label}`.trim(), + }); + const result = await generateText({ + model, + system: `You are the Researcher. Produce a checkpoint summary with your audit/opinion as JSON.${langInstruction}${doctrineContext ? `\n\n${doctrineContext}` : ""}`, + messages: [{ role: "user", content: prompt }], + abortSignal, + }); + + const budget = trackUsage(session.budget, "main_brain", `${completedNode.id}_ckpt`, result.usage?.totalTokens ?? 0); + await store.updateSession(session.id, { budget }); + + return safeParseJson(result.text); + } catch (err) { + console.error("[deep-research] Checkpoint generation failed:", err); + return { + title: `${completedNode.label} completed`, + humanSummary: `Step completed in context ${contextTag}.`, + }; + } +} + +async function getLatestReviewAssessment(sessionId: string): Promise<ReviewAssessment | undefined> { + const artifacts = await store.getArtifacts(sessionId, { type: "review_assessment" }); + if (artifacts.length === 0) return undefined; + return artifacts[artifacts.length - 1].content as unknown as ReviewAssessment; +} diff --git a/src/lib/deep-research/orchestrator-confirmation.ts b/src/lib/deep-research/orchestrator-confirmation.ts new file mode 100644 index 00000000..e7991d14 --- /dev/null +++ b/src/lib/deep-research/orchestrator-confirmation.ts @@ -0,0 +1,306 @@ +import { generateText } from "ai"; +import { getModelForRole, checkBudget, trackUsage } from "./model-router"; +import * as store from "./event-store"; +import { buildConfirmationInterpretationPrompt } from "./prompts"; +import { resolveTransition } from "./transition-resolver"; +import { normalizeAndLimitNodeSpecs } from "./dispatch-policy"; +import { buildResearcherDoctrinePromptBlock } from "./researcher-doctrine"; +import { extractJsonFromLLMResponse } from "./json-response"; +import { resolveLanguageState } from "./language-state"; +import { validateContextTag, resolveContextTagFromSpecs } from "./context-tag"; +import { canCompleteSession } from "./session-guards"; +import { cleanupFailedNodesFromFeedback } from "./session-hygiene"; +import type { WorkflowPolicy } from "./workflow-policy"; +import type { + CheckpointPackage, + ConfirmationDecision, + ConfirmationOutcome, + ContextTag, + DeepResearchNode, + DeepResearchSession, + NodeCreationSpec, +} from "./types"; + +type WorkflowRuntimeState = { + workflowPolicy: WorkflowPolicy; +}; + +export async function resumeAfterConfirmationInner(input: { + session: DeepResearchSession; + sessionId: string; + nodeId: string; + outcome: ConfirmationOutcome; + feedback: string | undefined; + abortSignal?: AbortSignal; + continueRun: (sessionId: string, abortSignal?: AbortSignal) => Promise<void>; + loadWorkflowRuntimeState: (session: DeepResearchSession) => Promise<WorkflowRuntimeState>; + createNodesFromSpecs: (sessionId: string, specs: NodeCreationSpec[], defaultContextTag: ContextTag) => Promise<DeepResearchNode[]>; +}): Promise<void> { + const { + session, + sessionId, + nodeId, + outcome, + feedback, + abortSignal, + continueRun, + loadWorkflowRuntimeState, + createNodesFromSpecs, + } = input; + + try { + await store.updateNode(nodeId, { + confirmedAt: new Date().toISOString(), + confirmedBy: "user", + confirmationOutcome: outcome, + }); + } catch { + console.warn(`[deep-research] Could not update node ${nodeId} with confirmation — may be a recovery path`); + } + + const eventMap: Record<ConfirmationOutcome, string> = { + confirmed: "user_confirmed", + revision_requested: "user_requested_revision", + branch_requested: "user_requested_branch", + rejected: "user_rejected_result", + stopped: "user_requested_stop", + }; + await store.appendEvent( + sessionId, + eventMap[outcome] as Parameters<typeof store.appendEvent>[1], + nodeId, + "user", + undefined, + undefined, + { outcome, feedback, explicitUserAction: true }, + ); + + const cleanupResult = await cleanupFailedNodesFromFeedback(sessionId, feedback); + if ( + cleanupResult.cleanedFailedNodeIds.length > 0 || + cleanupResult.cleanedBlockedNodeIds.length > 0 || + cleanupResult.cancelledExecutionRecordIds.length > 0 + ) { + await store.addMessage( + sessionId, + "system", + [ + cleanupResult.cleanedFailedNodeIds.length > 0 + ? `Cleaned failed nodes: ${cleanupResult.cleanedFailedNodeIds.join(", ")}.` + : null, + cleanupResult.cleanedBlockedNodeIds.length > 0 + ? `Cleaned blocked downstream nodes: ${cleanupResult.cleanedBlockedNodeIds.join(", ")}.` + : null, + cleanupResult.cancelledExecutionRecordIds.length > 0 + ? `Cancelled execution records: ${cleanupResult.cancelledExecutionRecordIds.join(", ")}.` + : null, + ].filter((line): line is string => Boolean(line)).join(" "), + ); + } + + if (outcome === "stopped") { + await store.updateSession(sessionId, { status: "stopped_by_user" }); + await store.addMessage(sessionId, "system", "Research stopped by user."); + return; + } + + const checkpoint = await loadCheckpointForConfirmation(sessionId, session.pendingCheckpointId); + if (!checkpoint) { + console.warn("[deep-research] No checkpoint found — using deterministic recovery path"); + + if (outcome === "confirmed") { + await store.updateSession(sessionId, { status: "running", pendingCheckpointId: null }); + await store.addMessage(sessionId, "system", `Resuming research in context: ${session.contextTag}`); + await continueRun(sessionId, abortSignal); + return; + } + + await store.addMessage( + sessionId, + "system", + "No checkpoint context available for revision. Please use 'Continue' to resume or 'Stop' to end the session.", + ); + return; + } + + const transitionAction = resolveTransition(session, checkpoint, outcome); + + let decision: ConfirmationDecision; + try { + decision = await callMainBrainForConfirmation( + session, + checkpoint, + outcome, + feedback, + abortSignal, + ); + } catch (err) { + console.error("[deep-research] callMainBrainForConfirmation failed, using deterministic fallback:", err); + decision = outcome === "confirmed" + ? { action: "continue", reasoning: "User confirmed. Following transition resolver.", nextContextTag: transitionAction.nextContextTag } + : outcome === "rejected" + ? { action: "stop", reasoning: "User rejected." } + : { action: "revise", reasoning: `User requested ${outcome}.` }; + } + + const confirmationWorkflowState = await loadWorkflowRuntimeState(session); + const limitedConfirmationSpecs = await normalizeAndLimitNodeSpecs( + sessionId, + decision.nodesToCreate ?? [], + session.contextTag, + confirmationWorkflowState.workflowPolicy, + "confirmation dispatch", + ); + decision = { + ...decision, + nodesToCreate: limitedConfirmationSpecs, + }; + + await store.updateSession(sessionId, { pendingCheckpointId: null }); + + if (decision.messageToUser) { + await store.addMessage(sessionId, "main_brain", decision.messageToUser, undefined, nodeId); + } + + if (checkpoint.isFinalStep && (outcome === "confirmed" || decision.action === "continue")) { + const nodes = await store.getNodes(sessionId); + const completionCheck = canCompleteSession(nodes); + if (completionCheck.allowed) { + await store.updateSession(sessionId, { status: "completed" }); + await store.appendEvent(sessionId, "session_completed", undefined, "system", undefined, undefined, { + completionReason: "User confirmed final step and all work complete", + }); + return; + } + + console.warn("[deep-research] Final step confirmed but work remains:", completionCheck.reason); + await store.addMessage( + sessionId, + "main_brain", + `Note: There is still pending work that should be addressed before full completion. ${completionCheck.reason}`, + ); + await store.updateSession(sessionId, { status: "final_report_generated" }); + return; + } + + switch (decision.action) { + case "continue": { + const nodesToCreate = decision.nodesToCreate?.length + ? decision.nodesToCreate + : transitionAction.nodesToCreate; + const targetContextTag = decision.nextContextTag + ? validateContextTag(decision.nextContextTag, resolveContextTagFromSpecs(nodesToCreate, transitionAction.nextContextTag)) + : resolveContextTagFromSpecs(nodesToCreate, transitionAction.nextContextTag); + if (nodesToCreate.length > 0) { + await createNodesFromSpecs(sessionId, nodesToCreate, targetContextTag); + } + await store.updateSession(sessionId, { status: "running", contextTag: targetContextTag }); + break; + } + case "revise": { + if (decision.nodesToCreate?.length) { + await createNodesFromSpecs(sessionId, decision.nodesToCreate, session.contextTag); + } + await store.updateSession(sessionId, { status: "running" }); + break; + } + case "retry": { + await store.updateSession(sessionId, { status: "running" }); + break; + } + case "branch": { + if (decision.nodesToCreate?.length) { + await createNodesFromSpecs(sessionId, decision.nodesToCreate, session.contextTag); + } + await store.updateSession(sessionId, { status: "running" }); + break; + } + case "supersede": { + if (decision.nodesToCreate?.length) { + await createNodesFromSpecs(sessionId, decision.nodesToCreate, session.contextTag); + } + const targetContextTag = decision.nextContextTag ? validateContextTag(decision.nextContextTag, session.contextTag) : session.contextTag; + await store.updateSession(sessionId, { status: "running", contextTag: targetContextTag }); + break; + } + case "stop": { + await store.updateSession(sessionId, { status: "stopped_by_user" }); + return; + } + default: { + console.warn(`[deep-research] Unknown confirmation action: "${decision.action}", following transition resolver`); + await store.updateSession(sessionId, { status: "running", contextTag: transitionAction.nextContextTag }); + } + } + + await continueRun(sessionId, abortSignal); +} + +async function callMainBrainForConfirmation( + session: DeepResearchSession, + checkpoint: CheckpointPackage, + outcome: ConfirmationOutcome, + feedback: string | undefined, + abortSignal?: AbortSignal, +): Promise<ConfirmationDecision> { + const budgetCheck = checkBudget("main_brain", session.budget, session.config.budget); + if (!budgetCheck.allowed) { + return { action: "stop", reasoning: "Budget limit reached" }; + } + + const nodes = await store.getNodes(session.id); + const artifacts = await store.getArtifacts(session.id); + const { model } = getModelForRole("main_brain", session.config); + const messages = await store.getMessages(session.id); + const langState = resolveLanguageState(messages); + const langNote = langState.preferredOutputLanguage !== "en" + ? `\nIMPORTANT: Respond in ${langState.preferredOutputLanguage} for any messageToUser field.` + : ""; + + const prompt = buildConfirmationInterpretationPrompt( + session, + checkpoint, + outcome, + feedback, + nodes, + artifacts, + ); + const doctrineContext = await buildResearcherDoctrinePromptBlock({ + contextTag: checkpoint.contextTag, + query: `${checkpoint.contextTag} ${checkpoint.title} ${feedback ?? ""}`.trim(), + }); + + const result = await generateText({ + model, + system: `You are the Researcher. Interpret the user's confirmation and decide how to proceed. Respond with JSON.${langNote}${doctrineContext ? `\n\n${doctrineContext}` : ""}`, + messages: [{ role: "user", content: prompt }], + abortSignal, + }); + + const budget = trackUsage(session.budget, "main_brain", `confirm_${session.contextTag}`, result.usage?.totalTokens ?? 0); + await store.updateSession(session.id, { budget }); + + try { + return extractJsonFromLLMResponse<ConfirmationDecision>(result.text); + } catch { + const transitionAction = resolveTransition(session, checkpoint, outcome); + return outcome === "confirmed" + ? { action: "continue", reasoning: "User confirmed.", nextContextTag: transitionAction.nextContextTag } + : { action: "revise", reasoning: "User requested changes." }; + } +} + +async function loadCheckpointForConfirmation( + sessionId: string, + pendingCheckpointId: string | null, +): Promise<CheckpointPackage | null> { + if (pendingCheckpointId) { + const artifact = await store.getArtifact(pendingCheckpointId); + if (artifact) { + return artifact.content as unknown as CheckpointPackage; + } + } + + const latest = await store.getLatestCheckpoint(sessionId); + return latest ? latest.content as unknown as CheckpointPackage : null; +} diff --git a/src/lib/deep-research/orchestrator-runtime.ts b/src/lib/deep-research/orchestrator-runtime.ts new file mode 100644 index 00000000..69376825 --- /dev/null +++ b/src/lib/deep-research/orchestrator-runtime.ts @@ -0,0 +1,582 @@ +import * as store from "./event-store"; +import { createInitialRequirements } from "./requirement-tracker"; +import { validateDAG, autoRepairDAG } from "./dag-validator"; +import { checkConsistency } from "./consistency-checker"; +import { executeNode } from "./node-executor"; +import { isFinalReportExecutionError } from "./final-report-runtime"; +import { buildWorkstationPlanningContext } from "./workstation-context"; +import { buildNodeContext, callMainBrain } from "./researcher-runtime"; +import { buildNodeCreationSpecDispatchPreviews } from "./node-spec-templates"; +import { + shouldPauseAfterResearcherStep, +} from "./checkpoint-policy"; +import { + deriveWorkflowPolicy, + type WorkflowPolicy, +} from "./workflow-policy"; +import { + countNodesByType, + normalizeAndLimitNodeSpecs, + resolveNodeDependencies, +} from "./dispatch-policy"; +import { canonicalizeArtifactReferenceFields } from "./artifact-references"; +import { assessFinalReportRetry } from "./final-report-retry-policy"; +import { + resolveContextTagFromSpecs, + resolveLegacyContextFromNodes, +} from "./context-tag"; +import { + canGenerateFinalReport, + checkEvidenceSufficiency, +} from "./session-guards"; +import { + buildSessionHygienePromptBlock, + reconcileSessionState, + type SessionHygieneSummary, +} from "./session-hygiene"; +import { resolveCheckpointInteractionMode } from "./orchestrator-checkpoint"; +import type { + CheckpointInteractionMode, + ContextTag, + DeepResearchArtifact, + DeepResearchNode, + DeepResearchSession, + LanguageState, + NodeCreationSpec, + RequirementState, +} from "./types"; + +export type WorkflowRuntimeState = { + messages: Awaited<ReturnType<typeof store.getMessages>>; + artifacts: Awaited<ReturnType<typeof store.getArtifacts>>; + workstationContext: Awaited<ReturnType<typeof buildWorkstationPlanningContext>>; + workflowPolicy: WorkflowPolicy; +}; + +export type ResearcherDispatchStepResult = { + completedNode: DeepResearchNode; + suggestedNextContextTag: ContextTag; + isFinalStep: boolean; + interactionMode: CheckpointInteractionMode; + plannedNodesToCreate: NodeCreationSpec[]; + requiresCheckpoint: boolean; +}; + +export async function executeApprovedNode( + session: DeepResearchSession, + node: DeepResearchNode, + nodes: DeepResearchNode[], + artifacts: DeepResearchArtifact[], + requirementState: RequirementState | null, + languageState: LanguageState, + abortSignal?: AbortSignal, +): Promise<{ + completedNode: DeepResearchNode; + suggestedNextContextTag: ContextTag; + isFinalStep: boolean; + requiresCheckpoint: boolean; + interactionMode: CheckpointInteractionMode; +}> { + if (node.nodeType === "final_report") { + const finalReportCheck = canGenerateFinalReport(nodes); + if (!finalReportCheck.allowed) { + const blockedNode = await createCompletedResearcherNode( + session.id, + "audit", + "Final report blocked — pending work remains", + { + blocked: true, + reason: finalReportCheck.reason, + pendingNodes: nodes + .filter((candidate) => + candidate.status !== "superseded" && + candidate.status !== "skipped" && + candidate.status !== "completed" && + candidate.status !== "failed" && + candidate.nodeType !== "final_report", + ) + .map((candidate) => ({ + id: candidate.id, + label: candidate.label, + status: candidate.status, + role: candidate.assignedRole, + })), + }, + "final_report", + ); + await store.addMessage( + session.id, + "main_brain", + `Cannot generate final report yet: ${finalReportCheck.reason}`, + ); + return { + completedNode: blockedNode, + suggestedNextContextTag: resolveLegacyContextFromNodes(nodes, session.contextTag), + isFinalStep: false, + requiresCheckpoint: true, + interactionMode: "confirmation", + }; + } + } + + if (node.nodeType === "synthesize") { + const evidenceCheck = checkEvidenceSufficiency(nodes, artifacts); + if (!evidenceCheck.canSynthesize) { + const blockedNode = await createCompletedResearcherNode( + session.id, + "audit", + "Evidence retrieval failed — no usable sources found", + { + blocked: true, + emptyStreams: evidenceCheck.emptyStreams, + recommendedAction: "Retry the approved literature tasks with adjusted search terms.", + }, + "planning", + ); + await store.addMessage( + session.id, + "main_brain", + `Evidence retrieval returned zero usable sources. Cannot synthesize findings from empty evidence. Failed/empty streams: ${evidenceCheck.emptyStreams.join(", ")}.`, + ); + return { + completedNode: blockedNode, + suggestedNextContextTag: "planning", + isFinalStep: false, + requiresCheckpoint: true, + interactionMode: "confirmation", + }; + } + } + + const nodeContext = await buildNodeContext(session.id); + try { + await executeNode(node, nodeContext, abortSignal); + } catch (error) { + if (node.nodeType === "final_report" && isFinalReportExecutionError(error)) { + const shouldReturnToPlanning = error.code === "insufficient_evidence"; + const blockedNode = await createCompletedResearcherNode( + session.id, + "audit", + shouldReturnToPlanning + ? "Final report blocked — targeted evidence supplement required" + : "Final report blocked — synthesis strategy needs revision", + { + blocked: true, + errorCode: error.code, + stage: error.stage, + reason: error.message, + recommendedAction: error.recommendedAction, + diagnostics: error.details, + failedNodeId: node.id, + }, + shouldReturnToPlanning ? "planning" : "final_report", + ); + await store.addMessage( + session.id, + "main_brain", + `${error.message}\n\nRecommended next action: ${error.recommendedAction}`, + ); + return { + completedNode: blockedNode, + suggestedNextContextTag: shouldReturnToPlanning ? "planning" : "final_report", + isFinalStep: false, + requiresCheckpoint: true, + interactionMode: "confirmation", + }; + } + throw error; + } + + const { freshNodes } = await runPostStepChecks(session, requirementState, node.contextTag); + const refreshedCompletedNode = freshNodes.find((candidate) => candidate.id === node.id) ?? node; + + return { + completedNode: refreshedCompletedNode, + suggestedNextContextTag: resolveLegacyContextFromNodes(freshNodes, node.contextTag), + isFinalStep: node.nodeType === "final_report", + requiresCheckpoint: false, + interactionMode: "confirmation", + }; +} + +export async function createResearcherDispatchStep( + session: DeepResearchSession, + nodes: DeepResearchNode[], + requirementState: RequirementState | null, + languageState: LanguageState, + hygieneSummary: SessionHygieneSummary, + workflowState: WorkflowRuntimeState, + abortSignal?: AbortSignal, +): Promise<ResearcherDispatchStepResult> { + const { workstationContext, workflowPolicy, artifacts } = workflowState; + const sessionHygienePromptBlock = buildSessionHygienePromptBlock(hygieneSummary); + const coordinationContext = [sessionHygienePromptBlock, workstationContext.promptBlock, workflowPolicy.promptBlock] + .filter((block): block is string => Boolean(block)) + .join("\n\n"); + const decision = await callMainBrain( + session, + abortSignal, + requirementState, + languageState.preferredOutputLanguage, + coordinationContext, + ); + + let limitedPlannedNodesToCreate = await normalizeAndLimitNodeSpecs( + session.id, + decision.nodesToCreate ?? [], + session.contextTag, + workflowPolicy, + "researcher planning", + ); + const finalReportRetry = assessFinalReportRetry({ nodes, artifacts }); + if (!finalReportRetry.allowed) { + const blockedFinalReportSpecs = limitedPlannedNodesToCreate.filter((spec) => spec.nodeType === "final_report"); + if (blockedFinalReportSpecs.length > 0) { + limitedPlannedNodesToCreate = limitedPlannedNodesToCreate.filter((spec) => spec.nodeType !== "final_report"); + await store.addMessage( + session.id, + "system", + `${finalReportRetry.reason} Remove the retry loop by adding new evidence/synthesis artifacts or by narrowing the requested deliverable before creating another final_report node.`, + ); + } + } + const interactionMode = resolveCheckpointInteractionMode(decision, limitedPlannedNodesToCreate); + + if (decision.messageToUser) { + await store.addMessage(session.id, "main_brain", decision.messageToUser); + } + + if ( + decision.action === "complete" + && limitedPlannedNodesToCreate.length === 0 + && !workflowPolicy.requiresInitialPlanConfirmation + ) { + if (!finalReportRetry.allowed) { + const blockedNode = await createCompletedResearcherNode( + session.id, + "audit", + "Final report retry blocked — no new upstream material", + { + blocked: true, + reason: finalReportRetry.reason, + failedAttemptCount: finalReportRetry.failedAttemptCount, + latestFailureNodeId: finalReportRetry.latestFailureNodeId, + }, + "final_report", + ); + await store.addMessage( + session.id, + "main_brain", + `${finalReportRetry.reason}\n\nAdd a new evidence/synthesis artifact or request a narrower deliverable before another final_report attempt.`, + ); + return { + completedNode: blockedNode, + suggestedNextContextTag: "final_report", + isFinalStep: false, + interactionMode: "confirmation", + plannedNodesToCreate: [], + requiresCheckpoint: true, + }; + } + + const finalReportCheck = canGenerateFinalReport(nodes); + if (!finalReportCheck.allowed) { + const blockedNode = await createCompletedResearcherNode( + session.id, + "audit", + "Researcher completion request blocked by active work", + { + blocked: true, + reason: finalReportCheck.reason, + decision, + }, + resolveLegacyContextFromNodes(nodes, session.contextTag), + ); + return { + completedNode: blockedNode, + suggestedNextContextTag: resolveLegacyContextFromNodes(nodes, session.contextTag), + isFinalStep: false, + interactionMode: "confirmation", + plannedNodesToCreate: [], + requiresCheckpoint: true, + }; + } + + const finalReportNode = await store.createNode(session.id, { + nodeType: "final_report", + label: "Generate final research report", + assignedRole: "research_asset_reuse_specialist", + input: { + decision, + workstationContext, + hygieneSummary, + }, + contextTag: "final_report", + }); + const executed = await executeApprovedNode( + session, + finalReportNode, + [...nodes, finalReportNode], + await store.getArtifacts(session.id), + requirementState, + languageState, + abortSignal, + ); + return { + ...executed, + plannedNodesToCreate: [], + requiresCheckpoint: true, + interactionMode: "confirmation", + }; + } + + const suggestedNextContextTag = resolveContextTagFromSpecs( + limitedPlannedNodesToCreate, + resolveLegacyContextFromNodes(nodes, session.contextTag), + ); + const requiresCheckpoint = shouldPauseAfterResearcherStep({ + interactionMode, + requiresInitialPlanConfirmation: workflowPolicy.requiresInitialPlanConfirmation, + plannedNodeCount: limitedPlannedNodesToCreate.length, + }); + + const completedNode = await createCompletedResearcherNode( + session.id, + "audit", + workflowPolicy.requiresInitialPlanConfirmation + ? "Researcher initial research plan" + : limitedPlannedNodesToCreate.length > 0 + ? "Researcher next-task recommendation" + : interactionMode === "answer_required" + ? "Researcher clarification request" + : "Researcher coordination audit", + { + decision, + workstationContext, + workflowPolicy: { + mode: workflowPolicy.mode, + reasoning: workflowPolicy.reasoning, + blockedNodeTypes: Array.from(workflowPolicy.blockedNodeTypes), + requiresInitialPlanConfirmation: workflowPolicy.requiresInitialPlanConfirmation, + }, + hygieneSummary, + proposedNodeSpecs: limitedPlannedNodesToCreate, + suggestedNextContextTag, + requiresUserConfirmation: requiresCheckpoint, + interactionMode, + }, + suggestedNextContextTag, + ); + + if (workflowPolicy.requiresInitialPlanConfirmation || limitedPlannedNodesToCreate.length > 0) { + const dispatchPreviews = buildNodeCreationSpecDispatchPreviews(limitedPlannedNodesToCreate); + await store.createArtifact( + session.id, + completedNode.id, + "task_graph", + workflowPolicy.requiresInitialPlanConfirmation ? "Researcher Initial Plan" : "Researcher Next Task", + { + nextTaskCount: limitedPlannedNodesToCreate.length, + nextTask: limitedPlannedNodesToCreate[0] ?? null, + nextTaskByType: countNodesByType(limitedPlannedNodesToCreate), + workstationContext, + workflowPolicy: { + mode: workflowPolicy.mode, + reasoning: workflowPolicy.reasoning, + blockedNodeTypes: Array.from(workflowPolicy.blockedNodeTypes), + requiresInitialPlanConfirmation: workflowPolicy.requiresInitialPlanConfirmation, + }, + hygieneSummary, + proposedNodeSpecs: limitedPlannedNodesToCreate, + dispatchPreviews, + suggestedNextContextTag, + requiresUserConfirmation: requiresCheckpoint, + interactionMode, + decision, + }, + ); + } + + await runPostStepChecks(session, requirementState, suggestedNextContextTag); + + return { + completedNode, + suggestedNextContextTag, + isFinalStep: false, + interactionMode, + plannedNodesToCreate: limitedPlannedNodesToCreate, + requiresCheckpoint, + }; +} + +export async function runPostStepChecks( + session: DeepResearchSession, + requirementState: RequirementState | null, + contextTagFallback: ContextTag, +): Promise<{ + freshNodes: DeepResearchNode[]; + freshArtifacts: DeepResearchArtifact[]; +}> { + const freshNodes = await store.getNodes(session.id); + const dagResult = validateDAG(freshNodes); + if (!dagResult.valid) { + const repairs = autoRepairDAG(freshNodes, dagResult.errors); + if (repairs.length > 0) { + console.warn("[deep-research] DAG auto-repaired:", repairs); + } + for (const err of dagResult.errors) { + if (err.type === "cycle") { + console.error("[deep-research] DAG cycle detected:", err.message); + } + } + } + + const freshArtifacts = await store.getArtifacts(session.id); + const consistency = checkConsistency({ ...session, contextTag: contextTagFallback }, freshNodes, freshArtifacts); + if (consistency.warnings.length > 0) { + console.warn("[deep-research] Consistency warnings:", consistency.warnings); + } + if (!consistency.valid) { + console.error("[deep-research] Consistency errors:", consistency.errors); + await store.appendEvent(session.id, "consistency_check", undefined, "system", undefined, undefined, { + valid: false, + errors: consistency.errors, + warnings: consistency.warnings, + }); + if (consistency.errors.some((error) => error.includes("CRITICAL"))) { + await store.updateSession(session.id, { + status: "failed", + error: `Consistency check failed: ${consistency.errors[0]}`, + }); + throw new Error(`Consistency check failed: ${consistency.errors[0]}`); + } + } + + if (!requirementState) { + const intakeArtifacts = freshArtifacts.filter((artifact) => artifact.artifactType === "research_brief"); + if (intakeArtifacts.length > 0) { + const reqState = createInitialRequirements(intakeArtifacts[0].content, "intake"); + await store.saveRequirementState(session.id, reqState); + } + } + + await reconcileSessionState(session.id); + + return { + freshNodes: await store.getNodes(session.id), + freshArtifacts: await store.getArtifacts(session.id), + }; +} + +async function createCompletedResearcherNode( + sessionId: string, + nodeType: DeepResearchNode["nodeType"], + label: string, + output: Record<string, unknown>, + contextTag: ContextTag, +): Promise<DeepResearchNode> { + const node = await store.createNode(sessionId, { + nodeType, + label, + assignedRole: "researcher", + input: output, + contextTag, + }); + await store.updateNode(node.id, { + status: "completed", + output, + completedAt: new Date().toISOString(), + }); + return node; +} + +export async function createNodesFromSpecs( + sessionId: string, + specs: NodeCreationSpec[], + defaultContextTag: ContextTag, +): Promise<DeepResearchNode[]> { + const session = await store.getSession(sessionId); + if (!session) { + throw new Error(`Session ${sessionId} not found`); + } + const existingNodes = await store.getNodes(sessionId); + const existingArtifacts = await store.getArtifacts(sessionId); + const existingNodeIds = new Set(existingNodes.map((node) => node.id)); + const existingNodeIdsByLabel = new Map<string, string>(); + for (const node of existingNodes) { + existingNodeIdsByLabel.set(node.label, node.id); + } + + const created: DeepResearchNode[] = []; + const createdNodeIdsByLabel = new Map<string, string>(); + const workflowState = await loadWorkflowRuntimeState(session); + const limitedSpecs = await normalizeAndLimitNodeSpecs( + sessionId, + specs, + defaultContextTag, + workflowState.workflowPolicy, + "node creation", + ); + + for (const normalizedSpec of limitedSpecs) { + const normalizedInput = normalizedSpec.input && typeof normalizedSpec.input === "object" + ? canonicalizeArtifactReferenceFields(normalizedSpec.input, existingArtifacts) + : normalizedSpec.input; + const node = await store.createNode(sessionId, { + ...normalizedSpec, + input: normalizedInput, + dependsOn: resolveNodeDependencies( + normalizedSpec.dependsOn ?? [], + existingNodeIds, + existingNodeIdsByLabel, + createdNodeIdsByLabel, + ), + contextTag: normalizedSpec.contextTag ?? defaultContextTag, + }); + createdNodeIdsByLabel.set(normalizedSpec.label, node.id); + created.push(node); + } + + for (let i = 0; i < created.length; i++) { + const normalizedSpec = limitedSpecs[i]; + const resolvedDependsOn = resolveNodeDependencies( + normalizedSpec.dependsOn ?? [], + existingNodeIds, + existingNodeIdsByLabel, + createdNodeIdsByLabel, + ); + if (JSON.stringify(created[i].dependsOn) !== JSON.stringify(resolvedDependsOn)) { + await store.updateNode(created[i].id, { dependsOn: resolvedDependsOn }); + } + } + + return created; +} + +export async function loadWorkflowRuntimeState( + session: DeepResearchSession, + provided?: Partial<Pick<WorkflowRuntimeState, "messages" | "artifacts">>, +): Promise<WorkflowRuntimeState> { + const messages = provided?.messages ?? await store.getMessages(session.id); + const artifacts = provided?.artifacts ?? await store.getArtifacts(session.id); + const workstationContext = await buildWorkstationPlanningContext(session, messages); + const workflowPolicy = deriveWorkflowPolicy({ + sessionTitle: session.title, + userMessages: getUserMessageContents(messages), + workstationContext, + artifacts, + }); + + return { + messages, + artifacts, + workstationContext, + workflowPolicy, + }; +} + +function getUserMessageContents(messages: Awaited<ReturnType<typeof store.getMessages>>): string[] { + return messages + .filter((message) => message.role === "user") + .map((message) => message.content); +} diff --git a/src/lib/deep-research/orchestrator.ts b/src/lib/deep-research/orchestrator.ts index 4feb15a6..1922a9a7 100644 --- a/src/lib/deep-research/orchestrator.ts +++ b/src/lib/deep-research/orchestrator.ts @@ -1,5 +1,5 @@ // ============================================================= -// Deep Research Orchestrator — Step-Gated Dispatcher +// Deep Research Orchestrator — Adaptive Dispatcher // ============================================================= // Invariants enforced: // A. No final_report while active branch has pending required nodes @@ -10,188 +10,44 @@ // F. Workflow routing is driven by active state, not fixed stage ordering // G. Language follows user -import { generateText } from "ai"; -import { nanoid } from "nanoid"; -import { getModelForRole, checkBudget, trackUsage } from "./model-router"; import * as store from "./event-store"; +import { resolveLanguageState } from "./language-state"; import { - buildCheckpointPrompt, - buildConfirmationInterpretationPrompt, -} from "./prompts"; -import { resolveTransition } from "./transition-resolver"; -import { createInitialRequirements } from "./requirement-tracker"; -import { validateDAG, autoRepairDAG } from "./dag-validator"; -import { checkConsistency } from "./consistency-checker"; -import { normalizeNodeCreationSpecs } from "./node-spec-normalizer"; -import { executeNode } from "./node-executor"; -import { buildWorkstationPlanningContext } from "./workstation-context"; -import { buildNodeContext, callMainBrain } from "./researcher-runtime"; -import { extractJsonFromLLMResponse, safeParseJson } from "./json-response"; -import { consolidateResearchMemory } from "./memory-fabric"; -import { buildResearcherDoctrinePromptBlock } from "./researcher-doctrine"; -import { buildNodeCreationSpecDispatchPreviews } from "./node-spec-templates"; + shouldPauseAfterCompletedNode, +} from "./checkpoint-policy"; import { - deriveWorkflowPolicy, - filterNodeSpecsForWorkflowPolicy, - type WorkflowPolicy, -} from "./workflow-policy"; -import { getStructuredPromptForNode, getStructuredRoleDisplayName } from "./role-registry"; + selectNextReadyNodeForWorkflow, +} from "./dispatch-policy"; import { - buildSessionHygienePromptBlock, - cleanupFailedNodesFromFeedback, reconcileSessionState, - type SessionHygieneSummary, } from "./session-hygiene"; +import { + generateCheckpointAndHalt, +} from "./orchestrator-checkpoint"; +import { resumeAfterConfirmationInner } from "./orchestrator-confirmation"; +import { + createNodesFromSpecs, + createResearcherDispatchStep, + executeApprovedNode, + loadWorkflowRuntimeState, +} from "./orchestrator-runtime"; import type { DeepResearchSession, - DeepResearchNode, - DeepResearchArtifact, - ContextTag, - ConfirmationDecision, ConfirmationOutcome, - CheckpointPackage, - NodeCreationSpec, - MainBrainAudit, - ReviewAssessment, - LanguageState, - RequirementState, - BrainDecision, - CheckpointInteractionMode, } from "./types"; -import { VALID_CONTEXT_TAGS } from "./types"; -type WorkflowRuntimeState = { - messages: Awaited<ReturnType<typeof store.getMessages>>; - artifacts: Awaited<ReturnType<typeof store.getArtifacts>>; - workstationContext: Awaited<ReturnType<typeof buildWorkstationPlanningContext>>; - workflowPolicy: WorkflowPolicy; +type RouteNextActionResult = { + shouldContinue: boolean; }; -// ============================================================= -// LANGUAGE DETECTION -// ============================================================= - -/** Detect primary language from text using simple heuristics. */ -function detectLanguage(text: string): string { - // Check for CJK characters (Chinese/Japanese/Korean) - const cjkChars = text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g); - const totalChars = text.replace(/\s/g, "").length; - if (cjkChars && cjkChars.length > totalChars * 0.1) return "zh"; - - // Check for Japanese-specific characters (hiragana/katakana) - const jpChars = text.match(/[\u3040-\u309f\u30a0-\u30ff]/g); - if (jpChars && jpChars.length > 5) return "ja"; - - // Check for Korean - const krChars = text.match(/[\uac00-\ud7af]/g); - if (krChars && krChars.length > 5) return "ko"; - - return "en"; -} - -/** Get or create language state from user messages. */ -function resolveLanguageState(messages: { role: string; content: string }[]): LanguageState { - const userMessages = messages.filter(m => m.role === "user"); - const latestUserMsg = userMessages[userMessages.length - 1]; - const lang = latestUserMsg ? detectLanguage(latestUserMsg.content) : "en"; - return { - currentUserLanguage: lang, - preferredOutputLanguage: lang, - lastDetectedUserLanguage: lang, - lastLanguageUpdateAt: new Date().toISOString(), - }; -} - -// ============================================================= -// INVARIANT CHECKS -// ============================================================= - -/** Invariant A: Check if final_report can proceed. */ -function canGenerateFinalReport(nodes: DeepResearchNode[]): { allowed: boolean; reason?: string } { - const activePending = nodes.filter(n => - n.status !== "superseded" && - n.status !== "skipped" && - n.status !== "completed" && - n.status !== "failed" && - n.nodeType !== "final_report" - ); - - if (activePending.length > 0) { - const labels = activePending.slice(0, 5).map(n => `"${n.label}" (${n.status})`).join(", "); - return { - allowed: false, - reason: `Cannot generate final report: ${activePending.length} required node(s) still pending/running: ${labels}`, - }; - } - - return { allowed: true }; -} - -/** Invariant B: Check if session can be marked completed. */ -function canCompleteSession(nodes: DeepResearchNode[]): { allowed: boolean; reason?: string } { - // Check for any non-terminal nodes that are NOT superseded - const activeNodes = nodes.filter(n => - n.status !== "superseded" && - n.status !== "skipped" && - n.status !== "completed" && - n.status !== "failed" - ); - - if (activeNodes.length > 0) { - const labels = activeNodes.slice(0, 5).map(n => `"${n.label}" (${n.status})`).join(", "); - return { - allowed: false, - reason: `Cannot complete session: ${activeNodes.length} node(s) still active: ${labels}`, - }; - } - - return { allowed: true }; -} - -/** Invariant D: Check evidence sufficiency for synthesis. */ -function checkEvidenceSufficiency(nodes: DeepResearchNode[], artifacts: DeepResearchArtifact[]): { - canSynthesize: boolean; - emptyStreams: string[]; - totalSources: number; -} { - const evidenceNodes = nodes.filter(n => - n.nodeType === "evidence_gather" && - (n.status === "completed" || n.status === "failed") - ); - - let totalSources = 0; - const emptyStreams: string[] = []; - - for (const node of evidenceNodes) { - const nodeArtifacts = artifacts.filter(a => a.nodeId === node.id && a.artifactType === "evidence_card"); - if (nodeArtifacts.length === 0) { - emptyStreams.push(node.label); - continue; - } - const content = nodeArtifacts[0].content; - const sources = (content.sources as unknown[]) ?? (content.claims as unknown[]) ?? []; - const totalFound = (content.totalFound as number) ?? (content.papersFound as number) ?? sources.length; - if (totalFound === 0 && sources.length === 0) { - emptyStreams.push(node.label); - } else { - totalSources += Math.max(totalFound, sources.length); - } - } - - return { - canSynthesize: totalSources > 0, - emptyStreams, - totalSources, - }; -} - // ============================================================= // PUBLIC API // ============================================================= /** - * Run ONE step of the deep research workflow, then halt at checkpoint. - * INVARIANT C: This function NEVER auto-confirms. It always halts at checkpoint. + * Run the deep research workflow until a user gate or terminal state is reached. + * INVARIANT C: This function NEVER auto-confirms. It only halts when explicit + * user input is required or the final report needs review. */ export async function runDeepResearch( sessionId: string, @@ -224,7 +80,30 @@ export async function runDeepResearch( if (abortSignal?.aborted) throw new Error("Aborted"); try { - await routeNextAction(session, abortSignal); + while (true) { + if (abortSignal?.aborted) throw new Error("Aborted"); + + const currentSession = await store.getSession(sessionId); + if (!currentSession) return; + + if (terminalStatuses.has(currentSession.status)) return; + if ( + currentSession.status === "awaiting_user_confirmation" + || currentSession.status === "awaiting_approval" + || currentSession.status === "awaiting_resource" + ) { + return; + } + + if (startableStatuses.has(currentSession.status) && currentSession.status !== "running") { + await store.updateSession(sessionId, { status: "running" }); + } + + const result = await routeNextAction(currentSession, abortSignal); + if (!result.shouldContinue) { + return; + } + } } catch (error) { const message = error instanceof Error ? error.message : "Unknown orchestrator error"; console.error(`[deep-research] Context error in "${session.contextTag}":`, message); @@ -256,8 +135,25 @@ export async function resumeAfterConfirmation( throw new Error(`Session is not awaiting confirmation (status: ${session.status})`); } + if (outcome !== "stopped") { + await store.updateSession(sessionId, { + status: "running", + pendingCheckpointId: null, + }); + } + try { - await _resumeAfterConfirmationInner(session, sessionId, nodeId, outcome, feedback, abortSignal); + await resumeAfterConfirmationInner({ + session, + sessionId, + nodeId, + outcome, + feedback, + abortSignal, + continueRun: runDeepResearch, + loadWorkflowRuntimeState, + createNodesFromSpecs, + }); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error during confirmation"; console.error(`[deep-research] resumeAfterConfirmation error:`, message); @@ -272,222 +168,18 @@ export async function resumeAfterConfirmation( } } -async function _resumeAfterConfirmationInner( - session: DeepResearchSession, - sessionId: string, - nodeId: string, - outcome: ConfirmationOutcome, - feedback: string | undefined, - abortSignal?: AbortSignal -): Promise<void> { - // Record the explicit user action (nodeId may be a fallback — update only if the node exists) - try { - await store.updateNode(nodeId, { - confirmedAt: new Date().toISOString(), - confirmedBy: "user", - confirmationOutcome: outcome, - }); - } catch { - // Node may not exist if this is a fallback/recovery scenario — that's OK - console.warn(`[deep-research] Could not update node ${nodeId} with confirmation — may be a recovery path`); - } - - const eventMap: Record<ConfirmationOutcome, string> = { - confirmed: "user_confirmed", - revision_requested: "user_requested_revision", - branch_requested: "user_requested_branch", - rejected: "user_rejected_result", - stopped: "user_requested_stop", - }; - await store.appendEvent( - sessionId, - eventMap[outcome] as Parameters<typeof store.appendEvent>[1], - nodeId, "user", undefined, undefined, - { outcome, feedback, explicitUserAction: true } - ); - - const cleanupResult = await cleanupFailedNodesFromFeedback(sessionId, feedback); - if ( - cleanupResult.cleanedFailedNodeIds.length > 0 || - cleanupResult.cleanedBlockedNodeIds.length > 0 || - cleanupResult.cancelledExecutionRecordIds.length > 0 - ) { - await store.addMessage( - sessionId, - "system", - [ - cleanupResult.cleanedFailedNodeIds.length > 0 - ? `Cleaned failed nodes: ${cleanupResult.cleanedFailedNodeIds.join(", ")}.` - : null, - cleanupResult.cleanedBlockedNodeIds.length > 0 - ? `Cleaned blocked downstream nodes: ${cleanupResult.cleanedBlockedNodeIds.join(", ")}.` - : null, - cleanupResult.cancelledExecutionRecordIds.length > 0 - ? `Cancelled execution records: ${cleanupResult.cancelledExecutionRecordIds.join(", ")}.` - : null, - ].filter((line): line is string => Boolean(line)).join(" "), - ); - } - - if (outcome === "stopped") { - await store.updateSession(sessionId, { status: "stopped_by_user" }); - await store.addMessage(sessionId, "system", "Research stopped by user."); - return; - } - - // Load checkpoint - let checkpoint: CheckpointPackage | null = null; - if (session.pendingCheckpointId) { - const art = await store.getArtifact(session.pendingCheckpointId); - if (art) checkpoint = art.content as unknown as CheckpointPackage; - } - if (!checkpoint) { - const latest = await store.getLatestCheckpoint(sessionId); - if (latest) checkpoint = latest.content as unknown as CheckpointPackage; - } - - if (!checkpoint) { - // No checkpoint found — this can happen if session was manually recovered - // or checkpoint was lost. Allow user to continue or stop, but use - // deterministic transition based on current context tag. - console.warn("[deep-research] No checkpoint found — using deterministic recovery path"); - - if (outcome === "confirmed") { - // Resume: set to running in the current context and re-enter orchestrator - await store.updateSession(sessionId, { status: "running", pendingCheckpointId: null }); - await store.addMessage(sessionId, "system", - `Resuming research in context: ${session.contextTag}`); - await runDeepResearch(sessionId, abortSignal); - return; - } - - // For revision/rejection without a checkpoint — stay blocked, inform user - await store.addMessage(sessionId, "system", - "No checkpoint context available for revision. Please use 'Continue' to resume or 'Stop' to end the session."); - return; - } - - // Compute transition - const transitionAction = resolveTransition(session, checkpoint, outcome); - - // Ask main brain to interpret - let decision: ConfirmationDecision; - try { - decision = await callMainBrainForConfirmation( - session, checkpoint, outcome, feedback, abortSignal - ); - } catch (err) { - console.error("[deep-research] callMainBrainForConfirmation failed, using deterministic fallback:", err); - // Deterministic fallback: follow the transition resolver exactly - decision = outcome === "confirmed" - ? { action: "continue", reasoning: "User confirmed. Following transition resolver.", nextContextTag: transitionAction.nextContextTag } - : outcome === "rejected" - ? { action: "stop", reasoning: "User rejected." } - : { action: "revise", reasoning: `User requested ${outcome}.` }; - } - - const confirmationWorkflowState = await loadWorkflowRuntimeState(session); - const limitedConfirmationSpecs = await normalizeAndLimitNodeSpecs( - sessionId, - decision.nodesToCreate ?? [], - session.contextTag, - confirmationWorkflowState.workflowPolicy, - "confirmation dispatch", - ); - decision = { - ...decision, - nodesToCreate: limitedConfirmationSpecs, - }; - - await store.updateSession(sessionId, { pendingCheckpointId: null }); - - if (decision.messageToUser) { - await store.addMessage(sessionId, "main_brain", decision.messageToUser, undefined, nodeId); - } - - // INVARIANT B: Before completing, verify all work is done - if (checkpoint.isFinalStep && (outcome === "confirmed" || decision.action === "continue")) { - const nodes = await store.getNodes(sessionId); - const completionCheck = canCompleteSession(nodes); - if (completionCheck.allowed) { - await store.updateSession(sessionId, { status: "completed" }); - await store.appendEvent(sessionId, "session_completed", undefined, "system", undefined, undefined, { - completionReason: "User confirmed final step and all work complete", - }); - return; - } else { - // Cannot complete yet — inform user and stay in a working state - console.warn("[deep-research] Final step confirmed but work remains:", completionCheck.reason); - await store.addMessage(sessionId, "main_brain", - `Note: There is still pending work that should be addressed before full completion. ${completionCheck.reason}`); - // Set to final_report_generated instead of completed - await store.updateSession(sessionId, { status: "final_report_generated" }); - return; - } - } - - switch (decision.action) { - case "continue": { - const nodesToCreate = decision.nodesToCreate?.length - ? decision.nodesToCreate - : transitionAction.nodesToCreate; - const targetContextTag = decision.nextContextTag - ? validateContextTag(decision.nextContextTag, resolveContextTagFromSpecs(nodesToCreate, transitionAction.nextContextTag)) - : resolveContextTagFromSpecs(nodesToCreate, transitionAction.nextContextTag); - if (nodesToCreate.length > 0) { - await createNodesFromSpecs(sessionId, nodesToCreate, targetContextTag); - } - await store.updateSession(sessionId, { status: "running", contextTag: targetContextTag }); - break; - } - case "revise": { - if (decision.nodesToCreate?.length) { - await createNodesFromSpecs(sessionId, decision.nodesToCreate, session.contextTag); - } - await store.updateSession(sessionId, { status: "running" }); - break; - } - case "retry": { - await store.updateSession(sessionId, { status: "running" }); - break; - } - case "branch": { - if (decision.nodesToCreate?.length) { - await createNodesFromSpecs(sessionId, decision.nodesToCreate, session.contextTag); - } - await store.updateSession(sessionId, { status: "running" }); - break; - } - case "supersede": { - if (decision.nodesToCreate?.length) { - await createNodesFromSpecs(sessionId, decision.nodesToCreate, session.contextTag); - } - const targetContextTag = decision.nextContextTag ? validateContextTag(decision.nextContextTag, session.contextTag) : session.contextTag; - await store.updateSession(sessionId, { status: "running", contextTag: targetContextTag }); - break; - } - case "stop": { - await store.updateSession(sessionId, { status: "stopped_by_user" }); - return; - } - default: { - // INVARIANT C: Do NOT auto-advance on unknown action. - // Default to following the transition resolver deterministically. - console.warn(`[deep-research] Unknown confirmation action: "${decision.action}", following transition resolver`); - await store.updateSession(sessionId, { status: "running", contextTag: transitionAction.nextContextTag }); - } - } - - await runDeepResearch(sessionId, abortSignal); -} - // ============================================================= // NODE ROUTER — Researcher-Decided Dispatch // ============================================================= -async function routeNextAction(session: DeepResearchSession, abortSignal?: AbortSignal): Promise<void> { +async function routeNextAction( + session: DeepResearchSession, + abortSignal?: AbortSignal, +): Promise<RouteNextActionResult> { const fresh = await store.getSession(session.id); - if (!fresh) return; + if (!fresh) { + return { shouldContinue: false }; + } const hygieneSummary = await reconcileSessionState(fresh.id); @@ -513,16 +205,26 @@ async function routeNextAction(session: DeepResearchSession, abortSignal?: Abort languageState, abortSignal, ); - await generateCheckpointAndHalt( - { ...fresh, contextTag: executed.suggestedNextContextTag }, - executed.completedNode, - executed.suggestedNextContextTag, - languageState, - abortSignal, - executed.isFinalStep, - "confirmation", - ); - return; + + if (executed.requiresCheckpoint || shouldPauseAfterCompletedNode({ isFinalStep: executed.isFinalStep })) { + await generateCheckpointAndHalt({ + session: { ...fresh, contextTag: executed.suggestedNextContextTag }, + completedNode: executed.completedNode, + suggestedNextContextTag: executed.suggestedNextContextTag, + languageState, + abortSignal, + isFinalStep: executed.isFinalStep, + interactionMode: executed.interactionMode, + }); + return { shouldContinue: false }; + } + + await store.updateSession(fresh.id, { + status: "running", + contextTag: executed.suggestedNextContextTag, + pendingCheckpointId: null, + }); + return { shouldContinue: true }; } const researcherStep = await createResearcherDispatchStep( @@ -535,1118 +237,32 @@ async function routeNextAction(session: DeepResearchSession, abortSignal?: Abort abortSignal, ); - await generateCheckpointAndHalt( - { ...fresh, contextTag: researcherStep.suggestedNextContextTag }, - researcherStep.completedNode, - researcherStep.suggestedNextContextTag, - languageState, - abortSignal, - researcherStep.isFinalStep, - researcherStep.interactionMode, - ); -} - -async function executeApprovedNode( - session: DeepResearchSession, - node: DeepResearchNode, - nodes: DeepResearchNode[], - artifacts: DeepResearchArtifact[], - requirementState: RequirementState | null, - languageState: LanguageState, - abortSignal?: AbortSignal, -): Promise<{ - completedNode: DeepResearchNode; - suggestedNextContextTag: ContextTag; - isFinalStep: boolean; -}> { - if (node.nodeType === "final_report") { - const finalReportCheck = canGenerateFinalReport(nodes); - if (!finalReportCheck.allowed) { - const blockedNode = await createCompletedResearcherNode( - session.id, - "audit", - "Final report blocked — pending work remains", - { - blocked: true, - reason: finalReportCheck.reason, - pendingNodes: nodes - .filter((candidate) => - candidate.status !== "superseded" && - candidate.status !== "skipped" && - candidate.status !== "completed" && - candidate.status !== "failed" && - candidate.nodeType !== "final_report" - ) - .map((candidate) => ({ - id: candidate.id, - label: candidate.label, - status: candidate.status, - role: candidate.assignedRole, - })), - }, - "final_report", - ); - await store.addMessage( - session.id, - "main_brain", - `Cannot generate final report yet: ${finalReportCheck.reason}`, - ); - return { - completedNode: blockedNode, - suggestedNextContextTag: resolveLegacyContextFromNodes(nodes, session.contextTag), - isFinalStep: false, - }; - } - } - - if (node.nodeType === "synthesize") { - const evidenceCheck = checkEvidenceSufficiency(nodes, artifacts); - if (!evidenceCheck.canSynthesize) { - const blockedNode = await createCompletedResearcherNode( - session.id, - "audit", - "Evidence retrieval failed — no usable sources found", - { - blocked: true, - emptyStreams: evidenceCheck.emptyStreams, - recommendedAction: "Retry the approved literature tasks with adjusted search terms.", - }, - "planning", - ); - await store.addMessage( - session.id, - "main_brain", - `Evidence retrieval returned zero usable sources. Cannot synthesize findings from empty evidence. Failed/empty streams: ${evidenceCheck.emptyStreams.join(", ")}.`, - ); - return { - completedNode: blockedNode, - suggestedNextContextTag: "planning", - isFinalStep: false, - }; - } - } - - const nodeContext = await buildNodeContext(session.id); - await executeNode(node, nodeContext, abortSignal); - - const { freshNodes } = await runPostStepChecks(session, requirementState, node.contextTag); - const refreshedCompletedNode = freshNodes.find((candidate) => candidate.id === node.id) ?? node; - - return { - completedNode: refreshedCompletedNode, - suggestedNextContextTag: resolveLegacyContextFromNodes(freshNodes, node.contextTag), - isFinalStep: node.nodeType === "final_report", - }; -} - -async function createResearcherDispatchStep( - session: DeepResearchSession, - nodes: DeepResearchNode[], - requirementState: RequirementState | null, - languageState: LanguageState, - hygieneSummary: SessionHygieneSummary, - workflowState: WorkflowRuntimeState, - abortSignal?: AbortSignal, -): Promise<{ - completedNode: DeepResearchNode; - suggestedNextContextTag: ContextTag; - isFinalStep: boolean; - interactionMode: CheckpointInteractionMode; -}> { - const { workstationContext, workflowPolicy } = workflowState; - const sessionHygienePromptBlock = buildSessionHygienePromptBlock(hygieneSummary); - const coordinationContext = [sessionHygienePromptBlock, workstationContext.promptBlock, workflowPolicy.promptBlock] - .filter((block): block is string => Boolean(block)) - .join("\n\n"); - const decision = await callMainBrain( - session, - abortSignal, - requirementState, - languageState.preferredOutputLanguage, - coordinationContext, - ); - - const limitedPlannedNodesToCreate = await normalizeAndLimitNodeSpecs( - session.id, - decision.nodesToCreate ?? [], - session.contextTag, - workflowPolicy, - "researcher planning", - ); - const interactionMode = resolveCheckpointInteractionMode(decision, limitedPlannedNodesToCreate); - - if (decision.messageToUser) { - await store.addMessage(session.id, "main_brain", decision.messageToUser); - } - - if ( - decision.action === "complete" - && limitedPlannedNodesToCreate.length === 0 - && !workflowPolicy.requiresInitialPlanConfirmation - ) { - const finalReportCheck = canGenerateFinalReport(nodes); - if (!finalReportCheck.allowed) { - const blockedNode = await createCompletedResearcherNode( - session.id, - "audit", - "Researcher completion request blocked by active work", - { - blocked: true, - reason: finalReportCheck.reason, - decision, - }, - resolveLegacyContextFromNodes(nodes, session.contextTag), - ); - return { - completedNode: blockedNode, - suggestedNextContextTag: resolveLegacyContextFromNodes(nodes, session.contextTag), - isFinalStep: false, - interactionMode: "confirmation", - }; - } - - const finalReportNode = await store.createNode(session.id, { - nodeType: "final_report", - label: "Generate final research report", - assignedRole: "research_asset_reuse_specialist", - input: { - decision, - workstationContext, - hygieneSummary, - }, - contextTag: "final_report", - }); - const executed = await executeApprovedNode( - session, - finalReportNode, - [...nodes, finalReportNode], - await store.getArtifacts(session.id), - requirementState, + if (researcherStep.requiresCheckpoint) { + await generateCheckpointAndHalt({ + session: { ...fresh, contextTag: researcherStep.suggestedNextContextTag }, + completedNode: researcherStep.completedNode, + suggestedNextContextTag: researcherStep.suggestedNextContextTag, languageState, abortSignal, - ); - return { - ...executed, - interactionMode: "confirmation", - }; - } - - const suggestedNextContextTag = resolveContextTagFromSpecs( - limitedPlannedNodesToCreate, - resolveLegacyContextFromNodes(nodes, session.contextTag), - ); - - const completedNode = await createCompletedResearcherNode( - session.id, - "audit", - workflowPolicy.requiresInitialPlanConfirmation - ? "Researcher initial research plan" - : limitedPlannedNodesToCreate.length > 0 - ? "Researcher next-task recommendation" - : interactionMode === "answer_required" - ? "Researcher clarification request" - : "Researcher coordination audit", - { - decision, - workstationContext, - workflowPolicy: { - mode: workflowPolicy.mode, - reasoning: workflowPolicy.reasoning, - blockedNodeTypes: Array.from(workflowPolicy.blockedNodeTypes), - requiresInitialPlanConfirmation: workflowPolicy.requiresInitialPlanConfirmation, - }, - hygieneSummary, - proposedNodeSpecs: limitedPlannedNodesToCreate, - suggestedNextContextTag, - requiresUserConfirmation: true, - interactionMode, - }, - suggestedNextContextTag, - ); - - if (workflowPolicy.requiresInitialPlanConfirmation || limitedPlannedNodesToCreate.length > 0) { - const dispatchPreviews = buildNodeCreationSpecDispatchPreviews(limitedPlannedNodesToCreate); - await store.createArtifact( - session.id, - completedNode.id, - "task_graph", - workflowPolicy.requiresInitialPlanConfirmation ? "Researcher Initial Plan" : "Researcher Next Task", - { - nextTaskCount: limitedPlannedNodesToCreate.length, - nextTask: limitedPlannedNodesToCreate[0] ?? null, - nextTaskByType: countNodesByType(limitedPlannedNodesToCreate), - workstationContext, - workflowPolicy: { - mode: workflowPolicy.mode, - reasoning: workflowPolicy.reasoning, - blockedNodeTypes: Array.from(workflowPolicy.blockedNodeTypes), - requiresInitialPlanConfirmation: workflowPolicy.requiresInitialPlanConfirmation, - }, - hygieneSummary, - proposedNodeSpecs: limitedPlannedNodesToCreate, - dispatchPreviews, - suggestedNextContextTag, - requiresUserConfirmation: true, - interactionMode, - decision, - }, - ); - } - - await runPostStepChecks(session, requirementState, suggestedNextContextTag); - - return { - completedNode, - suggestedNextContextTag, - isFinalStep: false, - interactionMode, - }; -} - -async function createCompletedResearcherNode( - sessionId: string, - nodeType: DeepResearchNode["nodeType"], - label: string, - output: Record<string, unknown>, - contextTag: ContextTag, -): Promise<DeepResearchNode> { - const node = await store.createNode(sessionId, { - nodeType, - label, - assignedRole: "researcher", - input: output, - contextTag, - }); - await store.updateNode(node.id, { - status: "completed", - output, - completedAt: new Date().toISOString(), - }); - return node; -} - -async function runPostStepChecks( - session: DeepResearchSession, - requirementState: RequirementState | null, - contextTagFallback: ContextTag, -): Promise<{ - freshNodes: DeepResearchNode[]; - freshArtifacts: DeepResearchArtifact[]; -}> { - const freshNodes = await store.getNodes(session.id); - const dagResult = validateDAG(freshNodes); - if (!dagResult.valid) { - const repairs = autoRepairDAG(freshNodes, dagResult.errors); - if (repairs.length > 0) { - console.warn("[deep-research] DAG auto-repaired:", repairs); - } - for (const err of dagResult.errors) { - if (err.type === "cycle") { - console.error("[deep-research] DAG cycle detected:", err.message); - } - } - } - - const freshArtifacts = await store.getArtifacts(session.id); - const consistency = checkConsistency({ ...session, contextTag: contextTagFallback }, freshNodes, freshArtifacts); - if (consistency.warnings.length > 0) { - console.warn("[deep-research] Consistency warnings:", consistency.warnings); - } - if (!consistency.valid) { - console.error("[deep-research] Consistency errors:", consistency.errors); - await store.appendEvent(session.id, "consistency_check", undefined, "system", undefined, undefined, { - valid: false, - errors: consistency.errors, - warnings: consistency.warnings, + isFinalStep: researcherStep.isFinalStep, + interactionMode: researcherStep.interactionMode, }); - if (consistency.errors.some((error) => error.includes("CRITICAL"))) { - await store.updateSession(session.id, { - status: "failed", - error: `Consistency check failed: ${consistency.errors[0]}`, - }); - throw new Error(`Consistency check failed: ${consistency.errors[0]}`); - } + return { shouldContinue: false }; } - if (!requirementState) { - const intakeArtifacts = freshArtifacts.filter((artifact) => artifact.artifactType === "research_brief"); - if (intakeArtifacts.length > 0) { - const reqState = createInitialRequirements(intakeArtifacts[0].content, "intake"); - await store.saveRequirementState(session.id, reqState); - } - } - - await reconcileSessionState(session.id); - - return { - freshNodes: await store.getNodes(session.id), - freshArtifacts: await store.getArtifacts(session.id), - }; -} - -function resolveLegacyContextFromNodes(nodes: DeepResearchNode[], fallback: ContextTag): ContextTag { - const activeNode = [...nodes] - .filter((node) => - node.status !== "superseded" && - node.status !== "skipped" && - node.status !== "completed" && - node.status !== "failed" - ) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0]; - - return activeNode?.contextTag ?? fallback; -} - -function countNodesByType(specs: NodeCreationSpec[]): Record<string, number> { - const counts: Record<string, number> = {}; - for (const spec of specs) { - counts[spec.nodeType] = (counts[spec.nodeType] ?? 0) + 1; - } - return counts; -} - -function getRecommendedDispatch( - freshNodes: DeepResearchNode[], - plannedSpecs: NodeCreationSpec[], -): { - roleId: NodeCreationSpec["assignedRole"]; - roleName: string; - nodeType: NodeCreationSpec["nodeType"]; - label: string; - promptUsed?: { - title: string; - kind: ReturnType<typeof getStructuredPromptForNode> extends infer T - ? T extends { kind: infer K } ? K : never - : never; - objective: string; - }; -} | null { - const pendingNode = freshNodes - .filter((node) => node.status === "pending" || node.status === "queued") - .sort((a, b) => a.createdAt.localeCompare(b.createdAt))[0]; - - const candidate = pendingNode - ? { - assignedRole: pendingNode.assignedRole, - nodeType: pendingNode.nodeType, - label: pendingNode.label, - } - : plannedSpecs[0]; - - if (!candidate) { - return null; - } - - const prompt = getStructuredPromptForNode(candidate.assignedRole, candidate.nodeType); - return { - roleId: candidate.assignedRole, - roleName: getStructuredRoleDisplayName(candidate.assignedRole, candidate.nodeType), - nodeType: candidate.nodeType, - label: candidate.label, - promptUsed: prompt - ? { - title: prompt.title, - kind: prompt.kind, - objective: prompt.objective, - } - : undefined, - }; -} - -// ============================================================= -// CHECKPOINT GENERATION WITH MAIN BRAIN AUDIT -// ============================================================= - -async function generateCheckpointAndHalt( - session: DeepResearchSession, - completedNode: DeepResearchNode, - suggestedNextContextTag: ContextTag, - languageState: LanguageState, - abortSignal?: AbortSignal, - isFinalStep = false, - interactionMode: CheckpointInteractionMode = "confirmation", -): Promise<void> { - const freshNodes = await store.getNodes(session.id); - const freshNode = freshNodes.find(n => n.id === completedNode.id) ?? completedNode; - const checkpointContextTag = freshNode.contextTag ?? session.contextTag; - const artifacts = await store.getArtifacts(session.id); - const isFinalReportingCheckpoint = isFinalStep || freshNode.nodeType === "final_report" || checkpointContextTag === "final_report"; - const finalReportCheckpointCopy = isFinalReportingCheckpoint - ? getFinalReportCheckpointCopy(languageState.preferredOutputLanguage) - : null; - const checkpointReviewArtifacts = getCheckpointReviewArtifacts(checkpointContextTag, freshNode, freshNodes, artifacts); - const literatureSummary = getEvidencePhaseSummary(checkpointContextTag, freshNodes, checkpointReviewArtifacts); - const planArtifact = artifacts.find((artifact) => - artifact.nodeId === freshNode.id && artifact.artifactType === "task_graph" - ); - const plannedSpecs = planArtifact && Array.isArray(planArtifact.content.proposedNodeSpecs) - ? normalizeNodeCreationSpecs(planArtifact.content.proposedNodeSpecs as unknown[], checkpointContextTag).validSpecs - : []; - const plannedNodeCount = typeof planArtifact?.content.nextTaskCount === "number" - ? planArtifact.content.nextTaskCount - : typeof planArtifact?.content.totalNodes === "number" - ? planArtifact.content.totalNodes - : 0; - const recommendedDispatch = getRecommendedDispatch(freshNodes, plannedSpecs); - - const transitionAction = { - nextContextTag: suggestedNextContextTag, - nodesToCreate: plannedSpecs, - nodesToSupersede: [], - description: isFinalReportingCheckpoint && finalReportCheckpointCopy - ? finalReportCheckpointCopy.continueWillDo - : interactionMode === "answer_required" - ? "Wait for the user to answer the Researcher's clarification questions in chat before any further work." - : plannedNodeCount > 0 - ? `If you confirm this next task, the Researcher will authorize it and continue coordination from ${suggestedNextContextTag}.` - : `Resume the session and let the Researcher choose the next work dynamically. Current recommendation: ${suggestedNextContextTag}.`, - }; - - // If this is flagged as final step, verify completion is actually allowed - if (isFinalStep) { - const completionCheck = canCompleteSession(freshNodes); - if (!completionCheck.allowed) { - console.warn("[deep-research] isFinalStep=true but completion blocked:", completionCheck.reason); - isFinalStep = false; // Downgrade — don't allow premature completion - } - } - - // Language instruction for checkpoint generation - const langInstruction = languageState.preferredOutputLanguage !== "en" - ? `\n\nIMPORTANT: The user communicates in ${languageState.preferredOutputLanguage}. Write all user-facing text (title, humanSummary, recommendedNextAction, continueWillDo) in ${languageState.preferredOutputLanguage}. Technical terms may remain in English.` - : ""; - - const checkpointContent = await generateCheckpointContent( - session, freshNode, artifacts, freshNodes, suggestedNextContextTag, langInstruction, abortSignal - ); - const guardedCheckpointContent = isFinalReportingCheckpoint - ? applyFinalReportCheckpointGuard(checkpointContent, languageState.preferredOutputLanguage) - : checkpointContent; - - const checkpointPkg: CheckpointPackage = { - checkpointId: nanoid(), - sessionId: session.id, - nodeId: freshNode.id, - stepType: freshNode.nodeType, - contextTag: checkpointContextTag, - title: guardedCheckpointContent.title || `${freshNode.label} completed`, - humanSummary: guardedCheckpointContent.humanSummary || `Completed: ${freshNode.label}`, - machineSummary: guardedCheckpointContent.machineSummary || "", - mainBrainAudit: guardedCheckpointContent.mainBrainAudit || { - whatWasCompleted: freshNode.label, - resultAssessment: "acceptable", - issuesAndRisks: [], - recommendedNextAction: isFinalReportingCheckpoint && finalReportCheckpointCopy - ? finalReportCheckpointCopy.recommendedNextAction - : recommendedDispatch - ? `Proceed to ${recommendedDispatch.roleName}: ${recommendedDispatch.label}` - : `Proceed to ${suggestedNextContextTag}`, - continueWillDo: transitionAction.description, - alternativeActions: [ - { label: "Revise", description: "Revise current step", actionType: "revise" }, - { label: "Stop", description: "End research", actionType: "stop" }, - ], - canProceed: true, - }, - artifactsToReview: checkpointReviewArtifacts.map(artifact => artifact.id), - currentFindings: guardedCheckpointContent.currentFindings || "", - openQuestions: guardedCheckpointContent.openQuestions || [], - recommendedNextAction: guardedCheckpointContent.recommendedNextAction || ( - isFinalReportingCheckpoint && finalReportCheckpointCopy - ? finalReportCheckpointCopy.recommendedNextAction - : recommendedDispatch - ? `Proceed to ${recommendedDispatch.roleName}: ${recommendedDispatch.label}` - : `Proceed to ${suggestedNextContextTag}` - ), - recommendedWorker: recommendedDispatch - ? { - roleId: recommendedDispatch.roleId, - roleName: recommendedDispatch.roleName, - nodeType: recommendedDispatch.nodeType, - label: recommendedDispatch.label, - } - : undefined, - promptUsed: recommendedDispatch?.promptUsed, - continueWillDo: transitionAction.description, - alternativeNextActions: guardedCheckpointContent.alternativeNextActions || [], - requiresUserConfirmation: true, - interactionMode, - isFinalStep, - transitionAction, - literatureRoundInfo: !isFinalReportingCheckpoint && session.literatureRound > 0 && literatureSummary ? { - roundNumber: session.literatureRound, - papersCollected: literatureSummary.papersCollected, - retrievalTaskCount: literatureSummary.retrievalTaskCount, - successfulTaskCount: literatureSummary.successfulTaskCount, - failedTaskCount: literatureSummary.failedTaskCount, - emptyTaskCount: literatureSummary.emptyTaskCount, - coverageSummary: guardedCheckpointContent.currentFindings || "", - } : undefined, - reviewInfo: isFinalReportingCheckpoint ? undefined : await getLatestReviewAssessment(session.id), - createdAt: new Date().toISOString(), - }; - - const checkpointArtifact = await store.createCheckpoint(session.id, freshNode.id, checkpointPkg); - await consolidateResearchMemory(session.id, { triggerNodeId: freshNode.id }); - - // ALWAYS halt at awaiting_user_confirmation — no auto-continue - await store.updateSession(session.id, { - status: "awaiting_user_confirmation", - contextTag: suggestedNextContextTag, - pendingCheckpointId: checkpointArtifact.id, - }); - - const audit = checkpointPkg.mainBrainAudit; - const auditSuffix = audit - ? interactionMode === "answer_required" - ? `\n\n**Assessment:** ${audit.resultAssessment}\n**Recommended:** ${audit.recommendedNextAction}\n**Reply required:** Answer the Researcher's clarification questions in chat before any task will continue.` - : `\n\n**Assessment:** ${audit.resultAssessment}\n**Recommended:** ${audit.recommendedNextAction}\n**"Continue" will:** ${checkpointPkg.continueWillDo}` - : ""; - await store.addMessage( - session.id, - "main_brain", - `**${checkpointPkg.title}**\n\n${checkpointPkg.humanSummary}${auditSuffix}`, - { checkpointId: checkpointArtifact.id }, - freshNode.id, - checkpointPkg.artifactsToReview - ); -} - -function resolveCheckpointInteractionMode( - decision: BrainDecision, - plannedNodesToCreate: NodeCreationSpec[], -): CheckpointInteractionMode { - if (decision.action === "respond_to_user" && plannedNodesToCreate.length === 0) { - return "answer_required"; - } - return "confirmation"; -} - -async function generateCheckpointContent( - session: DeepResearchSession, - completedNode: DeepResearchNode, - artifacts: DeepResearchArtifact[], - nodes: DeepResearchNode[], - contextTag: ContextTag, - langInstruction: string, - abortSignal?: AbortSignal -): Promise<{ - title?: string; - humanSummary?: string; - machineSummary?: string; - mainBrainAudit?: MainBrainAudit; - currentFindings?: string; - openQuestions?: string[]; - recommendedNextAction?: string; - continueWillDo?: string; - alternativeNextActions?: string[]; -}> { - const { model } = getModelForRole("main_brain", session.config); - const budgetCheck = checkBudget("main_brain", session.budget, session.config.budget); - if (!budgetCheck.allowed) { - return { - title: "Budget limit reached", - humanSummary: `Step "${completedNode.label}" completed but checkpoint generation budget exceeded.`, - mainBrainAudit: { - whatWasCompleted: completedNode.label, - resultAssessment: "acceptable", - issuesAndRisks: ["Budget limit reached"], - recommendedNextAction: "Review manually and decide", - continueWillDo: `Advance to ${contextTag}`, - alternativeActions: [], - canProceed: true, - }, - }; - } - - try { - const prompt = buildCheckpointPrompt(session, completedNode, artifacts, nodes, contextTag); - const doctrineContext = await buildResearcherDoctrinePromptBlock({ - contextTag, - query: `${completedNode.nodeType} ${completedNode.label}`.trim(), - }); - const result = await generateText({ - model, - system: `You are the Researcher. Produce a checkpoint summary with your audit/opinion as JSON.${langInstruction}${doctrineContext ? `\n\n${doctrineContext}` : ""}`, - messages: [{ role: "user", content: prompt }], - abortSignal, - }); - - const budget = trackUsage(session.budget, "main_brain", completedNode.id + "_ckpt", result.usage?.totalTokens ?? 0); - await store.updateSession(session.id, { budget }); - - return safeParseJson(result.text); - } catch (err) { - console.error("[deep-research] Checkpoint generation failed:", err); - return { - title: `${completedNode.label} completed`, - humanSummary: `Step completed in context ${contextTag}.`, - }; - } -} - -function applyFinalReportCheckpointGuard( - checkpointContent: { - title?: string; - humanSummary?: string; - machineSummary?: string; - mainBrainAudit?: MainBrainAudit; - currentFindings?: string; - openQuestions?: string[]; - recommendedNextAction?: string; - continueWillDo?: string; - alternativeNextActions?: string[]; - }, - preferredOutputLanguage: string, -): { - title?: string; - humanSummary?: string; - machineSummary?: string; - mainBrainAudit?: MainBrainAudit; - currentFindings?: string; - openQuestions?: string[]; - recommendedNextAction?: string; - continueWillDo?: string; - alternativeNextActions?: string[]; -} { - const copy = getFinalReportCheckpointCopy(preferredOutputLanguage); - return { - ...checkpointContent, - recommendedNextAction: copy.recommendedNextAction, - continueWillDo: copy.continueWillDo, - alternativeNextActions: copy.alternativeNextActions, - mainBrainAudit: checkpointContent.mainBrainAudit - ? { - ...checkpointContent.mainBrainAudit, - recommendedNextAction: copy.recommendedNextAction, - continueWillDo: copy.continueWillDo, - alternativeActions: checkpointContent.mainBrainAudit.alternativeActions.filter( - (action) => action.actionType !== "more_literature" - ), - } - : undefined, - }; -} - -function getFinalReportCheckpointCopy(preferredOutputLanguage: string): { - recommendedNextAction: string; - continueWillDo: string; - alternativeNextActions: string[]; -} { - if (preferredOutputLanguage.startsWith("zh")) { - return { - recommendedNextAction: "请审阅最终报告,并选择接受为本次研究结论,或提出定向修改意见;不要回退到早期的大范围文献检索轮次。", - continueWillDo: "继续将把这份最终报告作为当前研究交付物并结束本次会话;如果你希望补充内容,请选择修订并指出需要补充的具体证据或段落。", - alternativeNextActions: [ - "接受最终报告并结束本次研究", - "要求定向修订最终报告中的具体段落、论证或证据", - ], - }; - } - - return { - recommendedNextAction: "Review the final report and either accept it as the session outcome or request targeted revisions; do not restart broad literature rounds from earlier phases.", - continueWillDo: "Continue will finalize this report as the current research deliverable and close the session unless you request targeted revisions.", - alternativeNextActions: [ - "Accept the final report and close the session", - "Request targeted revisions to specific sections, claims, or supporting evidence", - ], - }; -} - -// ============================================================= -// MAIN BRAIN CALLS -// ============================================================= - -async function callMainBrainForConfirmation( - session: DeepResearchSession, - checkpoint: CheckpointPackage, - outcome: ConfirmationOutcome, - feedback: string | undefined, - abortSignal?: AbortSignal -): Promise<ConfirmationDecision> { - const budgetCheck = checkBudget("main_brain", session.budget, session.config.budget); - if (!budgetCheck.allowed) { - return { action: "stop", reasoning: "Budget limit reached" }; - } - - const nodes = await store.getNodes(session.id); - const artifacts = await store.getArtifacts(session.id); - const { model } = getModelForRole("main_brain", session.config); - - // Add language context - const messages = await store.getMessages(session.id); - const langState = resolveLanguageState(messages); - const langNote = langState.preferredOutputLanguage !== "en" - ? `\nIMPORTANT: Respond in ${langState.preferredOutputLanguage} for any messageToUser field.` - : ""; - - const prompt = buildConfirmationInterpretationPrompt( - session, checkpoint, outcome, feedback, nodes, artifacts - ); - const doctrineContext = await buildResearcherDoctrinePromptBlock({ - contextTag: checkpoint.contextTag, - query: `${checkpoint.contextTag} ${checkpoint.title} ${feedback ?? ""}`.trim(), - }); - - const result = await generateText({ - model, - system: `You are the Researcher. Interpret the user's confirmation and decide how to proceed. Respond with JSON.${langNote}${doctrineContext ? `\n\n${doctrineContext}` : ""}`, - messages: [{ role: "user", content: prompt }], - abortSignal, - }); - - const budget = trackUsage(session.budget, "main_brain", `confirm_${session.contextTag}`, result.usage?.totalTokens ?? 0); - await store.updateSession(session.id, { budget }); - - try { - return extractJsonFromLLMResponse<ConfirmationDecision>(result.text); - } catch { - // Deterministic fallback: follow transition resolver - const transitionAction = resolveTransition(session, checkpoint, outcome); - return outcome === "confirmed" - ? { action: "continue", reasoning: "User confirmed.", nextContextTag: transitionAction.nextContextTag } - : { action: "revise", reasoning: "User requested changes." }; - } -} - -// ============================================================= -// HELPERS -// ============================================================= - -async function getLatestReviewAssessment(sessionId: string): Promise<ReviewAssessment | undefined> { - const arts = await store.getArtifacts(sessionId, { type: "review_assessment" }); - if (arts.length === 0) return undefined; - return arts[arts.length - 1].content as unknown as ReviewAssessment; -} - -async function createNodesFromSpecs( - sessionId: string, - specs: NodeCreationSpec[], - defaultContextTag: ContextTag -): Promise<DeepResearchNode[]> { - const session = await store.getSession(sessionId); - if (!session) { - throw new Error(`Session ${sessionId} not found`); - } - const existingNodes = await store.getNodes(sessionId); - const existingNodeIds = new Set(existingNodes.map((node) => node.id)); - const existingNodeIdsByLabel = new Map<string, string>(); - for (const node of existingNodes) { - existingNodeIdsByLabel.set(node.label, node.id); - } - - const created: DeepResearchNode[] = []; - const createdNodeIdsByLabel = new Map<string, string>(); - const workflowState = await loadWorkflowRuntimeState(session); - const limitedSpecs = await normalizeAndLimitNodeSpecs( - sessionId, - specs, - defaultContextTag, - workflowState.workflowPolicy, - "node creation", - ); - - for (const normalizedSpec of limitedSpecs) { - const node = await store.createNode(sessionId, { - ...normalizedSpec, - dependsOn: resolveNodeDependencies( - normalizedSpec.dependsOn ?? [], - existingNodeIds, - existingNodeIdsByLabel, - createdNodeIdsByLabel, - ), - contextTag: normalizedSpec.contextTag ?? defaultContextTag, - }); - createdNodeIdsByLabel.set(normalizedSpec.label, node.id); - created.push(node); - } - - for (let i = 0; i < created.length; i++) { - const normalizedSpec = limitedSpecs[i]; - const resolvedDependsOn = resolveNodeDependencies( - normalizedSpec.dependsOn ?? [], - existingNodeIds, - existingNodeIdsByLabel, - createdNodeIdsByLabel, + if (researcherStep.plannedNodesToCreate.length > 0) { + await createNodesFromSpecs( + fresh.id, + researcherStep.plannedNodesToCreate, + researcherStep.suggestedNextContextTag, ); - if (JSON.stringify(created[i].dependsOn) !== JSON.stringify(resolvedDependsOn)) { - await store.updateNode(created[i].id, { dependsOn: resolvedDependsOn }); - } } - return created; -} - -function validateContextTag(contextTag: string | undefined, fallback: ContextTag): ContextTag { - if (!contextTag) return fallback; - const normalized = contextTag.trim().toLowerCase().replace(/[\s-]+/g, "_"); - if (VALID_CONTEXT_TAGS.includes(normalized as ContextTag)) return normalized as ContextTag; - if (normalized === "report") return "final_report"; - if (normalized === "plan") return "planning"; - if (normalized === "start") return "intake"; - return "planning"; -} - -function getCheckpointReviewArtifacts( - contextTag: ContextTag, - completedNode: DeepResearchNode, - nodes: DeepResearchNode[], - artifacts: DeepResearchArtifact[], -): DeepResearchArtifact[] { - if (!isLiteratureExecutionContext(contextTag, completedNode, nodes)) { - return artifacts.filter((artifact) => artifact.nodeId === completedNode.id); - } - - const relevantNodeIds = new Set( - nodes - .filter((node) => - node.nodeType === "evidence_gather" && - node.contextTag === contextTag && - ["completed", "failed", "skipped"].includes(node.status) - ) - .map((node) => node.id) - ); - - const evidenceArtifacts = artifacts.filter((artifact) => - artifact.artifactType === "evidence_card" && - Boolean(artifact.nodeId) && - relevantNodeIds.has(artifact.nodeId as string) - ); - - return evidenceArtifacts.length > 0 - ? evidenceArtifacts - : artifacts.filter((artifact) => artifact.nodeId === completedNode.id); -} - -function aggregateSourceCount(artifacts: DeepResearchArtifact[]): number { - return artifacts.reduce((sum, artifact) => { - const sources = Array.isArray(artifact.content.sources) ? artifact.content.sources : []; - const totalFound = typeof artifact.content.totalFound === "number" - ? artifact.content.totalFound - : typeof artifact.content.papersFound === "number" - ? artifact.content.papersFound - : sources.length; - return sum + Math.max(totalFound, sources.length); - }, 0); -} - -function getEvidencePhaseSummary( - contextTag: ContextTag, - nodes: DeepResearchNode[], - artifacts: DeepResearchArtifact[], -): { - papersCollected: number; - retrievalTaskCount: number; - successfulTaskCount: number; - failedTaskCount: number; - emptyTaskCount: number; -} | null { - const relevantCompletedNode = nodes.find((node) => - node.contextTag === contextTag && - ["completed", "failed", "skipped"].includes(node.status) - ); - if (!isLiteratureExecutionContext(contextTag, relevantCompletedNode, nodes)) { - return null; - } - - const relevantNodes = nodes.filter((node) => - node.nodeType === "evidence_gather" && - node.contextTag === contextTag && - ["completed", "failed", "skipped"].includes(node.status) - ); - - const artifactByNodeId = new Map( - artifacts - .filter((artifact) => artifact.artifactType === "evidence_card" && artifact.nodeId) - .map((artifact) => [artifact.nodeId as string, artifact]) - ); - - let successfulTaskCount = 0; - let failedTaskCount = 0; - let emptyTaskCount = 0; - - for (const node of relevantNodes) { - if (node.status === "failed") { - failedTaskCount += 1; - continue; - } - - const artifact = artifactByNodeId.get(node.id); - const sources = Array.isArray(artifact?.content.sources) ? artifact.content.sources : []; - const totalFound = typeof artifact?.content.totalFound === "number" - ? artifact.content.totalFound - : typeof artifact?.content.papersFound === "number" - ? artifact.content.papersFound - : sources.length; - - if (Math.max(totalFound, sources.length) > 0) { - successfulTaskCount += 1; - } else { - emptyTaskCount += 1; - } - } - - return { - papersCollected: aggregateSourceCount(artifacts), - retrievalTaskCount: relevantNodes.length, - successfulTaskCount, - failedTaskCount, - emptyTaskCount, - }; -} - -async function loadWorkflowRuntimeState( - session: DeepResearchSession, - provided?: Partial<Pick<WorkflowRuntimeState, "messages" | "artifacts">>, -): Promise<WorkflowRuntimeState> { - const messages = provided?.messages ?? await store.getMessages(session.id); - const artifacts = provided?.artifacts ?? await store.getArtifacts(session.id); - const workstationContext = await buildWorkstationPlanningContext(session, messages); - const workflowPolicy = deriveWorkflowPolicy({ - sessionTitle: session.title, - userMessages: getUserMessageContents(messages), - workstationContext, - artifacts, + await store.updateSession(fresh.id, { + status: "running", + contextTag: researcherStep.suggestedNextContextTag, + pendingCheckpointId: null, }); - return { - messages, - artifacts, - workstationContext, - workflowPolicy, - }; -} - -function getUserMessageContents(messages: Awaited<ReturnType<typeof store.getMessages>>): string[] { - return messages - .filter((message) => message.role === "user") - .map((message) => message.content); -} - -async function normalizeAndLimitNodeSpecs( - sessionId: string, - rawSpecs: unknown[], - defaultContextTag: ContextTag, - workflowPolicy: WorkflowPolicy, - source: string, -): Promise<NodeCreationSpec[]> { - const { validSpecs, droppedSpecs } = normalizeNodeCreationSpecs(rawSpecs, defaultContextTag); - if (droppedSpecs.length > 0) { - await store.addMessage( - sessionId, - "system", - `${droppedSpecs.length} malformed task(s) were ignored before ${source}.`, - ); - } - - const { allowedSpecs, blockedSpecs } = filterNodeSpecsForWorkflowPolicy(validSpecs, workflowPolicy); - if (blockedSpecs.length > 0) { - await store.addMessage( - sessionId, - "system", - `Blocked ${blockedSpecs.length} task(s) during ${source} because they do not fit the current workflow policy: ${blockedSpecs.map((spec) => `${spec.label} (${spec.nodeType})`).join(", ")}.`, - ); - } - - return enforceSingleWorkerDispatch(sessionId, allowedSpecs, source); -} - -async function selectNextReadyNodeForWorkflow( - sessionId: string, - workflowPolicy: WorkflowPolicy, -): Promise<DeepResearchNode | undefined> { - const readyNodes = (await store.getReadyNodes(sessionId)) - .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); - - const blockedReadyNodes = readyNodes.filter((node) => workflowPolicy.blockedNodeTypes.has(node.nodeType)); - if (blockedReadyNodes.length > 0) { - const completedAt = new Date().toISOString(); - for (const node of blockedReadyNodes) { - await store.updateNode(node.id, { - status: "skipped", - completedAt, - error: `Skipped by workflow policy (${workflowPolicy.mode}).`, - }); - } - await store.addMessage( - sessionId, - "system", - `Skipped ${blockedReadyNodes.length} ready node(s) because the session is currently in ${workflowPolicy.mode} mode: ${blockedReadyNodes.map((node) => `${node.label} (${node.nodeType})`).join(", ")}.`, - ); - } - - return readyNodes.find((node) => !workflowPolicy.blockedNodeTypes.has(node.nodeType)); -} - -async function enforceSingleWorkerDispatch( - sessionId: string, - specs: NodeCreationSpec[], - source: string, -): Promise<NodeCreationSpec[]> { - if (specs.length <= 1) { - return specs; - } - - await store.addMessage( - sessionId, - "system", - `${specs.length - 1} extra task(s) from ${source} were dropped. Deep Research now dispatches at most one worker task at a time.`, - ); - - return specs.slice(0, 1); -} - -function resolveContextTagFromSpecs(specs: NodeCreationSpec[], fallback: ContextTag): ContextTag { - const explicitContextTag = specs.find((spec): spec is NodeCreationSpec & { contextTag: ContextTag } => Boolean(spec.contextTag))?.contextTag; - return explicitContextTag ? validateContextTag(explicitContextTag, fallback) : fallback; -} - -function isLiteratureExecutionContext( - contextTag: ContextTag, - completedNode: DeepResearchNode | undefined, - nodes: DeepResearchNode[], -): boolean { - if (contextTag !== "planning") { - return false; - } - - if (completedNode?.nodeType === "evidence_gather") { - return true; - } - - return nodes.some((node) => - node.nodeType === "evidence_gather" && - node.contextTag === "planning" && - ["completed", "failed", "skipped"].includes(node.status) - ); -} - -function resolveNodeDependencies( - dependsOn: string[], - existingNodeIds: Set<string>, - existingNodeIdsByLabel: Map<string, string>, - createdNodeIdsByLabel: Map<string, string>, -): string[] { - const resolved = new Set<string>(); - - for (const dependency of dependsOn) { - if (existingNodeIds.has(dependency)) { - resolved.add(dependency); - continue; - } - - const existingMatch = existingNodeIdsByLabel.get(dependency); - if (existingMatch) { - resolved.add(existingMatch); - continue; - } - - const createdMatch = createdNodeIdsByLabel.get(dependency); - if (createdMatch) { - resolved.add(createdMatch); - } - } - - return [...resolved]; + return { shouldContinue: true }; } diff --git a/src/lib/deep-research/preprocessing.ts b/src/lib/deep-research/preprocessing.ts deleted file mode 100644 index 07225568..00000000 --- a/src/lib/deep-research/preprocessing.ts +++ /dev/null @@ -1,350 +0,0 @@ -// ============================================================= -// Deep Research — Preprocessing Pipeline Specification -// ============================================================= -// Pure utility module for defining and validating data preprocessing -// recipes. Generates Python commands for execution — does NOT run -// anything directly. - -// ------------------------------------------------------------------- -// Types -// ------------------------------------------------------------------- - -export type PreprocessingFormat = "jsonl" | "parquet" | "arrow" | "csv"; - -export interface PreprocessingRecipe { - id: string; - name: string; - inputPath: string; - outputPath: string; - outputFormat: PreprocessingFormat; - steps: PreprocessingStep[]; - splitConfig?: SplitConfig; - dedup?: DedupConfig; - contaminationFilter?: ContaminationFilterConfig; - sampleIdField?: string; -} - -export interface PreprocessingStep { - order: number; - name: string; - type: "filter" | "transform" | "normalize" | "dedup" | "split" | "sample" | "custom"; - config: Record<string, unknown>; - description: string; -} - -export interface SplitConfig { - trainRatio: number; - valRatio: number; - testRatio: number; - seed: number; - stratifyBy?: string; -} - -export interface DedupConfig { - method: "exact" | "minhash" | "simhash"; - threshold?: number; - fields: string[]; -} - -export interface ContaminationFilterConfig { - benchmarkDatasets: string[]; - method: "ngram" | "embedding" | "exact"; - ngramSize?: number; - threshold?: number; -} - -export interface DatasetStatistics { - totalSamples: number; - splits: Record<string, number>; - fields: string[]; - sampleIdCoverage: number; - dedupRemoved?: number; - contaminationRemoved?: number; -} - -// ------------------------------------------------------------------- -// Command generation -// ------------------------------------------------------------------- - -/** - * Generate a Python command that runs the preprocessing pipeline. - */ -export function buildPreprocessingCommand(recipe: PreprocessingRecipe): string { - const steps: string[] = []; - - steps.push(`import json, os, hashlib, random`); - steps.push(`from pathlib import Path`); - steps.push(``); - steps.push(`INPUT_PATH = "${recipe.inputPath}"`); - steps.push(`OUTPUT_PATH = "${recipe.outputPath}"`); - steps.push(`OUTPUT_FORMAT = "${recipe.outputFormat}"`); - steps.push(`os.makedirs(OUTPUT_PATH, exist_ok=True)`); - steps.push(``); - - // Load data - steps.push(`# Load input data`); - steps.push(`data = []`); - steps.push(`for f in sorted(Path(INPUT_PATH).glob("**/*.jsonl")) or [Path(INPUT_PATH)]:`); - steps.push(` with open(f) as fh:`); - steps.push(` data.extend(json.loads(line) for line in fh if line.strip())`); - steps.push(`print(f"Loaded {len(data)} samples")`); - steps.push(``); - - // Add sample IDs if configured - if (recipe.sampleIdField) { - steps.push(`# Add sample IDs`); - steps.push(`for i, item in enumerate(data):`); - steps.push(` if "${recipe.sampleIdField}" not in item:`); - steps.push(` item["${recipe.sampleIdField}"] = f"sample_{i}"`); - steps.push(``); - } - - // Preprocessing steps - for (const step of recipe.steps) { - steps.push(`# Step ${step.order}: ${step.name} (${step.type})`); - steps.push(`print("Running: ${step.name}")`); - - switch (step.type) { - case "filter": - steps.push(`data = [d for d in data if ${step.config.condition ?? "True"}]`); - break; - case "normalize": - steps.push(`for d in data:`); - steps.push(` for key in ${JSON.stringify(step.config.fields ?? [])}:`); - steps.push(` if key in d and isinstance(d[key], str):`); - steps.push(` d[key] = d[key].strip()`); - break; - case "sample": - steps.push(`random.seed(${step.config.seed ?? 42})`); - steps.push(`data = random.sample(data, min(${step.config.n ?? "len(data)"}, len(data)))`); - break; - case "dedup": - steps.push(`seen = set()`); - steps.push(`deduped = []`); - steps.push(`for d in data:`); - steps.push(` key = hashlib.md5(json.dumps(d, sort_keys=True).encode()).hexdigest()`); - steps.push(` if key not in seen:`); - steps.push(` seen.add(key)`); - steps.push(` deduped.append(d)`); - steps.push(`print(f"Dedup: {len(data)} -> {len(deduped)}")`); - steps.push(`data = deduped`); - break; - case "custom": - steps.push(String(step.config.code ?? "pass")); - break; - default: - steps.push(`pass # ${step.type}: ${step.description}`); - } - - steps.push(`print(f"After ${step.name}: {len(data)} samples")`); - steps.push(``); - } - - // Dedup (recipe-level) - if (recipe.dedup) { - steps.push(`# Recipe-level dedup (${recipe.dedup.method})`); - steps.push(`seen = set()`); - steps.push(`deduped = []`); - steps.push(`for d in data:`); - steps.push(` key_parts = [str(d.get(f, "")) for f in ${JSON.stringify(recipe.dedup.fields)}]`); - steps.push(` key = hashlib.md5("|".join(key_parts).encode()).hexdigest()`); - steps.push(` if key not in seen:`); - steps.push(` seen.add(key)`); - steps.push(` deduped.append(d)`); - steps.push(`print(f"Dedup removed {len(data) - len(deduped)} duplicates")`); - steps.push(`data = deduped`); - steps.push(``); - } - - // Split - if (recipe.splitConfig) { - const sc = recipe.splitConfig; - steps.push(`# Train/val/test split`); - steps.push(`random.seed(${sc.seed})`); - steps.push(`random.shuffle(data)`); - steps.push(`n = len(data)`); - steps.push(`n_train = int(n * ${sc.trainRatio})`); - steps.push(`n_val = int(n * ${sc.valRatio})`); - steps.push(`splits = {"train": data[:n_train], "val": data[n_train:n_train+n_val], "test": data[n_train+n_val:]}`); - steps.push(`for split_name, split_data in splits.items():`); - steps.push(` out = Path(OUTPUT_PATH) / f"{split_name}.${recipe.outputFormat}"`); - steps.push(` with open(out, "w") as f:`); - steps.push(` for d in split_data:`); - steps.push(` f.write(json.dumps(d, ensure_ascii=False) + "\\n")`); - steps.push(` print(f"{split_name}: {len(split_data)} samples -> {out}")`); - } else { - // Single output - steps.push(`# Write output`); - steps.push(`out = Path(OUTPUT_PATH) / "data.${recipe.outputFormat}"`); - steps.push(`with open(out, "w") as f:`); - steps.push(` for d in data:`); - steps.push(` f.write(json.dumps(d, ensure_ascii=False) + "\\n")`); - steps.push(`print(f"Output: {len(data)} samples -> {out}")`); - } - - // Statistics - steps.push(``); - steps.push(`# Write statistics`); - steps.push(`stats = {"totalSamples": len(data), "fields": list(data[0].keys()) if data else []}`); - steps.push(`with open(Path(OUTPUT_PATH) / "stats.json", "w") as f:`); - steps.push(` json.dump(stats, f, indent=2)`); - steps.push(`print("Done.")`); - - return `python3 -c '${steps.join("\n").replace(/'/g, "'\\''")}'`; -} - -// ------------------------------------------------------------------- -// Validation -// ------------------------------------------------------------------- - -/** - * Validate a preprocessing recipe for completeness. - */ -export function validateRecipe(recipe: PreprocessingRecipe): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!recipe.inputPath) errors.push("Missing inputPath"); - if (!recipe.outputPath) errors.push("Missing outputPath"); - if (!recipe.outputFormat) errors.push("Missing outputFormat"); - - const validFormats: PreprocessingFormat[] = ["jsonl", "parquet", "arrow", "csv"]; - if (!validFormats.includes(recipe.outputFormat)) { - errors.push(`Invalid outputFormat: ${recipe.outputFormat}`); - } - - // Validate steps - const orders = new Set<number>(); - for (const step of recipe.steps) { - if (orders.has(step.order)) { - errors.push(`Duplicate step order: ${step.order}`); - } - orders.add(step.order); - - if (!step.name) errors.push(`Step ${step.order}: missing name`); - if (!step.type) errors.push(`Step ${step.order}: missing type`); - } - - // Validate split config - if (recipe.splitConfig) { - const total = recipe.splitConfig.trainRatio + recipe.splitConfig.valRatio + recipe.splitConfig.testRatio; - if (Math.abs(total - 1.0) > 0.01) { - errors.push(`Split ratios sum to ${total}, expected ~1.0`); - } - } - - // Validate dedup config - if (recipe.dedup) { - if (!recipe.dedup.fields || recipe.dedup.fields.length === 0) { - errors.push("Dedup config has no fields specified"); - } - } - - return { valid: errors.length === 0, errors }; -} - -// ------------------------------------------------------------------- -// Split manifest generation -// ------------------------------------------------------------------- - -/** - * Generate a split manifest JSON from a recipe and statistics. - */ -export function generateSplitManifest( - recipe: PreprocessingRecipe, - stats: DatasetStatistics, -): Record<string, unknown> { - return { - recipeId: recipe.id, - recipeName: recipe.name, - outputPath: recipe.outputPath, - outputFormat: recipe.outputFormat, - totalSamples: stats.totalSamples, - splits: stats.splits, - fields: stats.fields, - sampleIdField: recipe.sampleIdField ?? null, - sampleIdCoverage: stats.sampleIdCoverage, - dedupRemoved: stats.dedupRemoved ?? 0, - contaminationRemoved: stats.contaminationRemoved ?? 0, - splitConfig: recipe.splitConfig ?? null, - generatedAt: new Date().toISOString(), - }; -} - -// ------------------------------------------------------------------- -// Duration estimation -// ------------------------------------------------------------------- - -/** - * Rough estimate of preprocessing duration based on data size and steps. - */ -export function estimatePreprocessingDuration(recipe: PreprocessingRecipe, estimatedSizeGb: number): string { - // Base: ~1 min per GB for simple JSONL processing - let minutes = estimatedSizeGb * 1; - - // Add time for complex steps - for (const step of recipe.steps) { - switch (step.type) { - case "dedup": minutes += estimatedSizeGb * 2; break; - case "transform": minutes += estimatedSizeGb * 0.5; break; - case "filter": minutes += estimatedSizeGb * 0.3; break; - } - } - - if (recipe.dedup) minutes += estimatedSizeGb * 3; - if (recipe.contaminationFilter) minutes += estimatedSizeGb * 5; - - if (minutes < 1) return "< 1 min"; - if (minutes < 60) return `~${Math.ceil(minutes)} min`; - return `~${(minutes / 60).toFixed(1)} hours`; -} - -// ------------------------------------------------------------------- -// Default recipe -// ------------------------------------------------------------------- - -/** - * Create a sensible default preprocessing recipe. - */ -export function createDefaultRecipe( - inputPath: string, - outputPath: string, - format: PreprocessingFormat = "jsonl", -): PreprocessingRecipe { - return { - id: `recipe_${Date.now()}`, - name: "Default preprocessing", - inputPath, - outputPath, - outputFormat: format, - steps: [ - { - order: 1, - name: "Normalize whitespace", - type: "normalize", - config: { fields: ["text", "content", "input", "output"] }, - description: "Strip and normalize whitespace in text fields", - }, - { - order: 2, - name: "Remove empty samples", - type: "filter", - config: { condition: "len(str(d.get('text', d.get('content', '')))) > 0" }, - description: "Remove samples with empty text content", - }, - { - order: 3, - name: "Exact dedup", - type: "dedup", - config: {}, - description: "Remove exact duplicate samples", - }, - ], - splitConfig: { - trainRatio: 0.9, - valRatio: 0.05, - testRatio: 0.05, - seed: 42, - }, - sampleIdField: "sample_id", - }; -} diff --git a/src/lib/deep-research/prompt-builders/checkpoint-prompt.ts b/src/lib/deep-research/prompt-builders/checkpoint-prompt.ts new file mode 100644 index 00000000..b0e955eb --- /dev/null +++ b/src/lib/deep-research/prompt-builders/checkpoint-prompt.ts @@ -0,0 +1,238 @@ +import type { + CheckpointPackage, + ConfirmationOutcome, + ContextTag, + DeepResearchArtifact, + DeepResearchNode, + DeepResearchSession, +} from "../types"; + +export function buildCheckpointPrompt( + session: DeepResearchSession, + completedNode: DeepResearchNode, + artifacts: DeepResearchArtifact[], + nodes: DeepResearchNode[], + contextTag: ContextTag, +): string { + const isFinalReportingStep = completedNode.nodeType === "final_report" || contextTag === "final_report"; + const relevantNodeIds = isEvidenceAggregationPhase(contextTag, completedNode, nodes) + ? new Set( + nodes + .filter((node) => + node.nodeType === "evidence_gather" && + node.contextTag === contextTag && + ["completed", "failed", "skipped"].includes(node.status), + ) + .map((node) => node.id), + ) + : null; + + const nodeArtifacts = relevantNodeIds + ? artifacts.filter((artifact) => + artifact.artifactType === "evidence_card" && + Boolean(artifact.nodeId) && + relevantNodeIds.has(artifact.nodeId as string), + ) + : artifacts.filter((artifact) => artifact.nodeId === completedNode.id); + + const evidenceTotalSources = nodeArtifacts.reduce((sum, artifact) => { + const sources = Array.isArray(artifact.content.sources) ? artifact.content.sources : []; + const totalFound = typeof artifact.content.totalFound === "number" + ? artifact.content.totalFound + : typeof artifact.content.papersFound === "number" + ? artifact.content.papersFound + : sources.length; + return sum + Math.max(totalFound, sources.length); + }, 0); + + const artifactPreviews = nodeArtifacts.map((artifact) => { + const contentString = JSON.stringify(artifact.content); + return ` - [short=${artifact.id.slice(0, 8)} | id=${artifact.id}] ${artifact.title} (${artifact.artifactType}): ${contentString.length > 400 ? `${contentString.slice(0, 400)}...` : contentString}`; + }).join("\n"); + + const allNodesSummary = nodes.map((node) => + ` - [short=${node.id.slice(0, 8)} | id=${node.id}] ${node.label} (${node.nodeType}, ${node.status}, context=${node.contextTag})`, + ).join("\n"); + + const reviewAssessments = isFinalReportingStep + ? artifacts.filter((artifact) => artifact.artifactType === "review_assessment").slice(-1) + : artifacts.filter((artifact) => artifact.artifactType === "review_assessment"); + const reviewSection = reviewAssessments.length > 0 + ? `\n## Review Assessments\n${reviewAssessments.map((review) => JSON.stringify(review.content, null, 2)).join("\n")}` + : ""; + const finalReportingRule = isFinalReportingStep + ? ` + +## Final Report Phase Rule +- You are already in the report-writing/final-report phase. +- Do NOT recommend restarting broad literature discovery, restarting literature round counting, or returning to an earlier "round 1/N" style search loop. +- The default next action here is to let the user review/accept the final report or request targeted revisions. +- Only recommend additional literature work if there is a clearly blocking evidence gap that prevents the report from standing as a final deliverable, and frame it as a targeted revision request rather than a restart of the workflow. +` + : ""; + + return `You have just completed a step in a step-gated deep research workflow. +The system will HALT and present your summary to the user for review. + +## Completed Step +- Node: "${completedNode.label}" (${completedNode.nodeType}) +- Role: ${completedNode.assignedRole} +- Status: ${completedNode.status} +- Context Tag: ${contextTag} +${isEvidenceAggregationPhase(contextTag, completedNode, nodes) ? `- Aggregated evidence cards in this literature execution context: ${nodeArtifacts.length}\n- Aggregated sources/papers found in this literature execution context: ${evidenceTotalSources}` : ""} + +## Artifacts Produced +${artifactPreviews || " (none)"} + +## Current Task Graph +${allNodesSummary || " (none)"} +${reviewSection} +${finalReportingRule} + +## Session +- Title: "${session.title}" +- Literature round: ${session.literatureRound} +- Reviewer round: ${session.reviewerRound} +- Execution loop: ${session.executionLoop} +- Tokens used: ${session.budget.totalTokens} / ${session.config.budget.maxTotalTokens} + +## Instructions +Produce a checkpoint summary with your AUDIT/OPINION as JSON: +{ + "title": "Short title for this checkpoint", + "humanSummary": "Clear 2-5 sentence summary for the user. Be specific.", + "machineSummary": "Compact internal summary for your own context.", + "mainBrainAudit": { + "whatWasCompleted": "Description of what this stage accomplished", + "resultAssessment": "good|acceptable|concerning|problematic", + "issuesAndRisks": ["Issue 1", "Issue 2"], + "recommendedNextAction": "What you recommend doing next", + "continueWillDo": "Exactly what clicking Continue will do", + "alternativeActions": [ + {"label": "Continue", "description": "Proceed with recommendation", "actionType": "continue"}, + {"label": "Revise", "description": "Change approach", "actionType": "revise"}, + {"label": "More Literature", "description": "Search for more papers", "actionType": "more_literature"}, + {"label": "Stop", "description": "End research", "actionType": "stop"} + ], + "canProceed": true + }, + "currentFindings": "What we know so far", + "openQuestions": ["Question 1"], + "recommendedNextAction": "What should happen next", + "continueWillDo": "Exactly what clicking Continue will trigger", + "alternativeNextActions": ["Alternative 1"], + "requiresUserConfirmation": true +} + +IMPORTANT: The "continueWillDo" field must clearly state what "Continue" means at this point. +Example: "Continue will let the Researcher route the next workflow, beginning with 3 literature-analysis tasks searching for papers." +NOT vague: "Continue will continue the research." +- When you mention node or artifact identifiers in any proposed next task, use the full canonical id shown as \`id=...\`, not the short display prefix.`; +} + +export function buildConfirmationInterpretationPrompt( + session: DeepResearchSession, + checkpoint: CheckpointPackage, + outcome: ConfirmationOutcome, + userFeedback: string | undefined, + nodes: DeepResearchNode[], + artifacts: DeepResearchArtifact[], +): string { + const nodesSummary = nodes.map((node) => + ` - [short=${node.id.slice(0, 8)} | id=${node.id}] ${node.label} (${node.nodeType}, ${node.status})`, + ).join("\n"); + + const latestTaskGraph = artifacts + .filter((artifact) => artifact.artifactType === "task_graph") + .slice(-1)[0]; + const proposedPlanSummary = latestTaskGraph + ? JSON.stringify({ + title: latestTaskGraph.title, + nextTaskCount: latestTaskGraph.content.nextTaskCount ?? latestTaskGraph.content.totalNodes, + skillsUsed: latestTaskGraph.content.skillsUsed, + suggestedNextContextTag: latestTaskGraph.content.suggestedNextContextTag, + nextTask: latestTaskGraph.content.nextTask ?? latestTaskGraph.content.proposedNodeSpecs, + }, null, 2) + : "(no task_graph artifact available)"; + const isFinalReportingCheckpoint = checkpoint.isFinalStep || checkpoint.contextTag === "final_report"; + const finalReportRule = isFinalReportingCheckpoint + ? ` + +## Final-Report Confirmation Rule +This checkpoint is already in the final report phase. +- If the user confirms, treat that as accepting the final report path rather than reopening broad literature discovery. +- Do NOT dispatch new evidence-gather work unless the user explicitly asks to reopen literature review or requests targeted evidence additions. +- If the user wants changes, prefer targeted revisions to the report or its supporting claims instead of restarting from an earlier literature round.` + : ""; + + return `The user has responded to a checkpoint in the step-gated deep research workflow. + +## Checkpoint That Was Presented +- Title: "${checkpoint.title}" +- Context Tag: ${checkpoint.contextTag} +- Summary: ${checkpoint.humanSummary} +- Your recommended next: ${checkpoint.recommendedNextAction} +- "Continue" was described as: ${checkpoint.continueWillDo || checkpoint.recommendedNextAction} + +## User's Response +- Outcome: ${outcome} +${userFeedback ? `- Feedback: "${userFeedback}"` : "- (no additional feedback)"} + +## Current Task Graph +${nodesSummary} + +## Latest Next-Task Plan +${proposedPlanSummary} +${finalReportRule} + +## CRITICAL SEMANTIC RULE +"Continue" means: proceed according to YOUR recommended next action. +It does NOT mean "blindly run the old pipeline." It means the user accepts YOUR recommendation. + +## Next-Task Confirmation Rule +If the checkpoint included a task_graph artifact and the user confirmed it: +- treat the approved next-task artifact as authorized for dispatch; +- return only the NEXT approved worker task in "nodesToCreate"; +- set "nextContextTag" to "planning" unless the approved work is a final report. + +## Literature-Dispatch Rule +If the user confirmed a checkpoint that recommends literature work: +- return at most one explicit evidence_gather task in "nodesToCreate" when new literature work is required; +- assign those tasks to "literature_intelligence_analyst"; +- do not rely on any hidden runtime handler to fabricate fallback searches. + +## Re-Planning Rule +If the checkpoint proposed a next task and the user feedback changes core objectives, task division, time nodes, or resources: +- do NOT dispatch workers; +- return "action": "revise" or "branch" and keep the workflow in planning. + +## Instructions +Respond with JSON: +{ + "action": "continue" | "revise" | "retry" | "branch" | "supersede" | "stop", + "reasoning": "Brief explanation", + "nodesToCreate": [/* optional */], + "nextContextTag": "optional context tag", + "messageToUser": "optional message" +}`; +} + +function isEvidenceAggregationPhase( + contextTag: ContextTag, + completedNode: DeepResearchNode, + nodes: DeepResearchNode[], +): boolean { + if (contextTag !== "planning") { + return false; + } + + if (completedNode.nodeType === "evidence_gather") { + return true; + } + + return nodes.some((node) => + node.nodeType === "evidence_gather" && + node.contextTag === "planning" && + ["completed", "failed", "skipped"].includes(node.status), + ); +} diff --git a/src/lib/deep-research/prompt-builders/final-report-prompt.ts b/src/lib/deep-research/prompt-builders/final-report-prompt.ts new file mode 100644 index 00000000..60705c5b --- /dev/null +++ b/src/lib/deep-research/prompt-builders/final-report-prompt.ts @@ -0,0 +1,1330 @@ +import { buildRuntimeRoleContract } from "../role-registry"; +import { extractChapterPacketsFromArtifacts, selectChapterPacketsForSection } from "../summary-packets"; +import type { + ChapterPacket, + DeepResearchArtifact, + DeepResearchMessage, + DeepResearchNode, + DeepResearchSession, +} from "../types"; + +interface FinalReportCitationEntry { + citationKey: string; + title: string; + year?: number; + venue?: string; + url?: string; + doi?: string; + query: string; +} + +export interface FinalReportCitationCoverage { + availableCitationCount: number; + citedCitationCount: number; + minimumRequiredCitationCount: number; + hasReferencesSection: boolean; + meetsCoverage: boolean; + missingCitationKeys: string[]; +} + +export type FinalReportSectionKind = "introduction" | "body" | "conclusion"; + +export interface FinalReportSectionPlanItem { + id: string; + title: string; + kind: FinalReportSectionKind; + summary: string; + targetTakeaway: string; + citationFocus: string[]; +} + +export interface FinalReportSectionPlan { + reportTitle: string; + sections: FinalReportSectionPlanItem[]; +} + +export type FinalReportDigestMode = "standard" | "compact"; + +export interface FinalReportReadiness { + canDraft: boolean; + status: "ready" | "thin_evidence" | "insufficient_evidence"; + summary: string; + recommendedAction: string; + totalRelevantArtifactCount: number; + selectedArtifactCount: number; + evidenceCardCount: number; + synthesisArtifactCount: number; + availableCitationCount: number; + totalSourceCount: number; +} + +export interface FinalReportPromptBundle { + digestMode: FinalReportDigestMode; + artifactDigest: string; + citationRegistry: string; + readiness: FinalReportReadiness; +} + +export function isSurveyLikeResearchRequest(sessionTitle: string, userMessages: string[]): boolean { + const requestText = [sessionTitle, ...userMessages].join("\n"); + return /(综述|调研|梳理|总结|review|survey|taxonomy|landscape|comparison|mechanism)/i.test(requestText); +} + +export function buildFinalReportSystemPrompt( + node: DeepResearchNode, +): string { + const roleContract = buildRuntimeRoleContract(node.assignedRole, "final_report", { + includeResponsibilities: true, + includeCollaboration: true, + includePerformance: true, + maxItemsPerSection: 3, + }); + + return `You are the Research Asset Reuse Specialist responsible for writing the final research deliverable. + +## Core Mission +- Produce the actual final report for the user, not a short status update. +- Synthesize prior evidence, summaries, and critiques into a coherent markdown document. +- Keep every substantive claim grounded in the provided artifacts. +- State uncertainty explicitly where evidence is weak, missing, or contradictory. + +## Structured Role Contract +${roleContract || " (no structured role contract available)"} + +## Output Rules +- Write the final answer as raw markdown only. +- Do NOT return JSON. +- Do NOT wrap the report in code fences. +- Do NOT output a short chat reply, checklist, or planning memo. +- Prefer a complete report with clear sections, comparisons, limitations, and future directions. +- The report must read like a serious academic survey or rigorous research review, not a blog post or lightweight memo. +- Prefer substantial prose and analytical argumentation over bullet-heavy exposition. +- Each major section must contain a coherent argumentative arc rather than a loose collection of observations. +- When the user asked for a survey / review / overview / taxonomy / mechanism summary, produce a literature-review-style report rather than a brief conclusion note. +- Cite provenance inline when possible using source titles, model names, benchmark names, years, or artifact labels from the provided context. +- If some parts of the request are under-supported, explicitly label them as uncertain instead of inventing details.`; +} + +export function buildFinalReportPlannerSystemPrompt( + node: DeepResearchNode, +): string { + const roleContract = buildRuntimeRoleContract(node.assignedRole, "final_report", { + includeResponsibilities: true, + includeCollaboration: true, + includePerformance: true, + maxItemsPerSection: 3, + }); + + return `You are the planning coordinator for a multi-agent final-report writing workflow. + +## Structured Role Contract +${roleContract || " (no structured role contract available)"} + +## Mission +- Decide how many major sections the final report should contain. +- Produce a section-by-section writing plan before drafting begins. +- Ensure the outline is coherent, academically rigorous, and suitable for parallel section drafting. +- Treat introduction and conclusion as special sections. + +## Output Rules +- Return valid JSON only. +- Do NOT write the report body yet. +- The plan must include one introduction section and one conclusion section. +- Body sections should be specific enough that independent section-writing agents can draft them without guessing. +- The outline must be complete enough to support a full paper-level draft, not a sparse or skeletal report. +- The full outline should progress logically from motivation and setup to taxonomy, core comparisons, empirical or critical analysis, and synthesis. +- The final report will be drafted in this order: body sections first, introduction second-to-last, conclusion last. +- Even though introduction is drafted late, it will appear at the beginning of the final assembled paper.`; +} + +export function buildFinalReportPrompt( + session: DeepResearchSession, + messages: DeepResearchMessage[], + artifacts: DeepResearchArtifact[], + node: DeepResearchNode, +): string { + const userMessages = messages + .filter((message) => message.role === "user") + .map((message) => message.content.trim()) + .filter((content) => content.length > 0); + const latestUserMessage = userMessages[userMessages.length - 1] ?? session.title; + const shouldRespondInChinese = /[\u4e00-\u9fff]/.test(latestUserMessage) || /[\u4e00-\u9fff]/.test(session.title); + const isSurveyLikeRequest = isSurveyLikeResearchRequest(session.title, userMessages); + + const promptBundle = buildFinalReportPromptBundle(session, messages, artifacts); + const citationEntries = buildFinalReportCitationEntries(artifacts); + const artifactDigest = promptBundle.artifactDigest; + const citationRegistry = promptBundle.citationRegistry; + const minimumRequiredCitations = getMinimumRequiredCitationCount(citationEntries.length, isSurveyLikeRequest); + const requestedSections = Array.isArray(node.input?.deliverableSections) + ? (node.input.deliverableSections as unknown[]) + .filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + const requestedAudience = typeof node.input?.targetAudience === "string" && node.input.targetAudience.trim().length > 0 + ? node.input.targetAudience + : "research stakeholders"; + + const surveySectionGuide = shouldRespondInChinese + ? [ + "1. 摘要", + "2. 问题定义与任务背景", + "3. 时间序列 Transformer 架构谱系 / 分类框架", + "4. 代表性模型与关键设计选择比较", + "5. 数据集、评测协议与常见指标", + "6. 优势、局限与适用场景", + "7. 证据缺口、争议点与开放问题", + "8. 结论", + "9. 参考线索(列出主要论文/模型/工件来源)", + ].join("\n") + : [ + "1. Executive Summary", + "2. Problem Setting And Background", + "3. Taxonomy Of Time-Series Transformer Architectures", + "4. Representative Models And Design Trade-offs", + "5. Datasets, Evaluation Protocols, And Metrics", + "6. Strengths, Limitations, And Best-Fit Scenarios", + "7. Evidence Gaps, Disagreements, And Open Problems", + "8. Conclusion", + "9. Reference Trail (major papers/models/artifacts used)", + ].join("\n"); + + const generalSectionGuide = shouldRespondInChinese + ? [ + "1. 摘要", + "2. 研究范围与目标", + "3. 证据基础", + "4. 核心发现", + "5. 对比分析与局限", + "6. 结论与后续建议", + "7. 参考线索", + ].join("\n") + : [ + "1. Executive Summary", + "2. Scope And Objective", + "3. Evidence Base", + "4. Main Findings", + "5. Comparative Analysis And Limitations", + "6. Conclusion And Next Steps", + "7. Reference Trail", + ].join("\n"); + + return `Write the final research report for this Deep Research session. + +## Session +- Session title: ${session.title} +- Deliverable node: ${node.label} +- Target audience: ${requestedAudience} +- Preferred output language: ${shouldRespondInChinese ? "Chinese" : "Match the user's language; default to English"} +- Report style: ${isSurveyLikeRequest ? "Detailed survey / literature review" : "Detailed analytical report"} + +## User Request +${userMessages.length > 0 ? userMessages.map((content, index) => `${index + 1}. ${content}`).join("\n") : session.title} + +## Report Inputs +${artifactDigest || "(No supporting artifacts were available. If so, explicitly state that the evidence base is thin.)"} + +## Citation Registry +${citationRegistry || "(No structured citation registry available. If so, cite artifact titles and make uncertainty explicit.)"} + +## Citation Coverage Target +- Unique citations available in registry: ${citationEntries.length} +- Minimum unique citations expected in this report: ${minimumRequiredCitations} +- Do not concentrate references only on the most famous 8-15 papers if the registry is much larger. +- Spread citations across major subtopics / technical routes / architecture families when the evidence supports it. + +## Required Report Properties +- Produce a detailed markdown report, not a short note. +- Keep the report concrete: mention actual architectures, models, datasets, benchmarks, evidence patterns, and limitations when the artifacts support them. +- Explicitly reconcile conflicting evidence instead of averaging it away. +- When evidence is insufficient, say exactly what is missing. +- Include a clear evidence-coverage section or paragraphs that explain what the report is based on. +- Make the report self-contained enough that a reader can understand the topic without reopening the raw artifacts. +- Use headings and subheadings; do not collapse the answer into a few bullet points. +- Prefer direct statements over vague phrases like "some studies" or "various methods". + +## Requested Deliverable Sections +${requestedSections.length > 0 ? requestedSections.map((section, index) => `${index + 1}. ${section}`).join("\n") : "(none explicitly provided)"} + +## Minimum Section Structure +${isSurveyLikeRequest ? surveySectionGuide : generalSectionGuide} + +## Important Writing Rules +- Output raw markdown only. +- Do not output JSON. +- Do not output code fences around the entire report. +- ${shouldRespondInChinese ? "请直接使用中文撰写报告;论文名、模型名、数据集名可保留英文。" : "Write in the user's language when clear from context."} +- If the user asked for a survey or overview, make the document comprehensive and structured like a real survey, not a brief recap. +- The paper must be detailed, complete, and logically strong. +- Each major section should have enough depth to stand on its own rather than being a short placeholder. +- Build explicit logical bridges between sections so the report reads as one coherent academic paper. +- Prefer paragraph-level argument development: claim -> evidence -> comparison -> implication. +- When comparing methods, explain not only what differs, but why those differences matter. +- Do not leave major subtopics merely implied; make the structure and reasoning explicit. +- Use inline citations throughout the report, not only in a final bibliography. +- For factual claims, comparisons, historical statements, benchmark summaries, or architecture descriptions, append an inline citation immediately after the relevant sentence or clause. +- Prefer citation forms like ${shouldRespondInChinese ? "`[Informer, 2021]`、`[Autoformer, 2021]`、`[FEDformer, 2022]`" : "`[Informer, 2021]`, `[Autoformer, 2021]`, `[FEDformer, 2022]`"}; if a URL is important, include it in the References section rather than cluttering the main text. +- Include a dedicated "References" / "参考文献与来源线索" section at the end, listing the main cited papers, models, repositories, or artifacts with year and URL/venue when available. +- Keep the survey logically progressive: background -> taxonomy -> representative methods -> empirical patterns -> limitations -> open problems. +- Avoid unsupported omnibus claims. If evidence is mixed, say so and cite the competing sources inline. +- If the citation registry is large, ensure the report references a broad representative subset instead of a narrow canonical core. +- End with a concise synthesis of what is well-supported, what remains uncertain, and what future work is most justified.`; +} + +export function buildFinalReportSectionPlanPrompt( + session: DeepResearchSession, + messages: DeepResearchMessage[], + artifacts: DeepResearchArtifact[], + node: DeepResearchNode, + options?: { + digestMode?: FinalReportDigestMode; + artifactDigestOverride?: string; + preferredArtifactIds?: string[]; + }, +): string { + const userMessages = messages + .filter((message) => message.role === "user") + .map((message) => message.content.trim()) + .filter((content) => content.length > 0); + const shouldRespondInChinese = /[\u4e00-\u9fff]/.test(session.title) || userMessages.some((message) => /[\u4e00-\u9fff]/.test(message)); + const isSurveyLikeRequest = isSurveyLikeResearchRequest(session.title, userMessages); + const promptBundle = buildFinalReportPromptBundle(session, messages, artifacts, { + digestMode: options?.digestMode, + preferredArtifactIds: options?.preferredArtifactIds, + }); + const citationEntries = buildFinalReportCitationEntries(artifacts); + const artifactDigest = options?.artifactDigestOverride ?? promptBundle.artifactDigest; + + return `Plan the final report before drafting any section. + +## Session +- Title: ${session.title} +- Preferred language: ${shouldRespondInChinese ? "Chinese" : "English"} +- Report style: ${isSurveyLikeRequest ? "Academic survey / literature review" : "Analytical report"} +- Target audience: ${typeof node.input?.targetAudience === "string" && node.input.targetAudience.trim().length > 0 ? node.input.targetAudience : "research stakeholders"} + +## User Request +${userMessages.length > 0 ? userMessages.map((content, index) => `${index + 1}. ${content}`).join("\n") : session.title} + +## Available Evidence +${artifactDigest || "(No structured artifact digest available.)"} + +## Citation Inventory +- Unique citations available: ${citationEntries.length} + +## Planning Rules +- Decide the final paper's major sections first. +- Include one introduction and one conclusion. +- Use 3-7 body sections by default unless the evidence clearly justifies a different count. +- The outline must be complete enough to support a full paper-level draft, not a sparse or skeletal report. +- Each body section should correspond to a real analytical unit with its own central question and conclusion. +- The full outline should progress logically from motivation and setup to taxonomy, core comparisons, empirical or critical analysis, and synthesis. +- For every section, provide: + - title + - kind: introduction | body | conclusion + - summary: what the section should cover + - targetTakeaway: what the reader should learn from the section + - citationFocus: the main topics / papers / evidence clusters the writer should prioritize +- Introduction should be drafted second-to-last and must synthesize the body sections. +- Conclusion should be drafted last and must synthesize the entire paper. +- Body sections should be mutually non-overlapping and logically ordered. + +## Output JSON Schema +{ + "reportTitle": "title", + "sections": [ + { + "id": "section_1", + "title": "section title", + "kind": "introduction|body|conclusion", + "summary": "what this section covers", + "targetTakeaway": "what the section should conclude or make clear", + "citationFocus": ["topic or citation cluster 1", "topic or citation cluster 2"] + } + ] +}`; +} + +export function buildFinalReportPromptBundle( + session: DeepResearchSession, + messages: DeepResearchMessage[], + artifacts: DeepResearchArtifact[], + options?: { + digestMode?: FinalReportDigestMode; + preferredArtifactIds?: string[]; + }, +): FinalReportPromptBundle { + const userMessages = messages + .filter((message) => message.role === "user") + .map((message) => message.content.trim()) + .filter((content) => content.length > 0); + const isSurveyLikeRequest = isSurveyLikeResearchRequest(session.title, userMessages); + const digestMode = options?.digestMode ?? "standard"; + const relevantArtifacts = selectArtifactsForFinalReport(artifacts, { + digestMode, + preferredArtifactIds: options?.preferredArtifactIds, + }); + const citationEntries = buildFinalReportCitationEntries(artifacts); + + return { + digestMode, + artifactDigest: relevantArtifacts.map((artifact) => formatArtifactForFinalReport(artifact, { digestMode })).join("\n\n"), + citationRegistry: buildCitationRegistry( + citationEntries, + digestMode === "compact" ? 18 : 40, + ), + readiness: assessFinalReportReadiness({ + artifacts, + relevantArtifacts, + citationEntries, + isSurveyLikeRequest, + }), + }; +} + +export function buildFinalReportSectionDraftPrompt(input: { + sessionTitle: string; + preferredOutputLanguage: "zh" | "en"; + section: FinalReportSectionPlanItem; + sectionPlan: FinalReportSectionPlan; + artifactDigest: string; + citationRegistry: string; + sectionPackets?: ChapterPacket[]; + draftedBodySections?: Array<{ title: string; content: string }>; + draftedFullSections?: Array<{ title: string; content: string }>; + referenceExcerptLimit?: number; +}): string { + const planOutline = input.sectionPlan.sections.map((section, index) => + `${index + 1}. ${section.title} [${section.kind}] - ${section.summary}` + ).join("\n"); + const relevantPacketDigest = formatChapterPacketReferences(input.sectionPackets ?? []); + + if (input.section.kind === "introduction") { + const bodyReference = formatDraftedSectionReferences( + input.draftedBodySections ?? [], + input.referenceExcerptLimit ?? 1200, + ); + return `Draft the introduction section for the final report. + +## Writing Order Note +- This introduction is being drafted after the body sections. +- In the final paper it must appear at the beginning. + +## Report Title +${input.sessionTitle} + +## Full Outline +${planOutline} + +## Introduction Plan +- Title: ${input.section.title} +- Summary: ${input.section.summary} +- Target takeaway: ${input.section.targetTakeaway} +- Citation focus: ${input.section.citationFocus.join(", ") || "(use the most relevant body-grounding citations)"} + +## Drafted Body Sections To Reference +${bodyReference || "(No body drafts available; fall back to the report plan and evidence digest.)"} + +## Relevant Chapter Packets +${relevantPacketDigest || "(No chapter packets available.)"} + +## Evidence Digest +${input.artifactDigest || "(No evidence digest available.)"} + +## Citation Registry +${input.citationRegistry || "(No citation registry available.)"} + +## Output Rules +- Output markdown for this section only. +- Start with a level-2 heading: \`## ${input.section.title}\` +- Do not write the conclusion. +- Use the body sections as the main reference frame for scope, framing, and terminology. +- Write a full academic introduction, not a short abstract-like preface. +- The introduction should explain motivation, scope, organization, and the analytical lens used by the body sections. +- Use the drafted body sections to make the introduction logically aligned with the actual paper content. +- Use citation keys from the relevant chapter packets when making concrete claims. +- ${input.preferredOutputLanguage === "zh" ? "请直接使用中文撰写。" : "Write in English."}`; + } + + if (input.section.kind === "conclusion") { + const fullReference = formatDraftedSectionReferences( + input.draftedFullSections ?? [], + input.referenceExcerptLimit ?? 1200, + ); + return `Draft the conclusion section for the final report. + +## Writing Order Note +- This conclusion is being drafted last. +- It must synthesize the full paper rather than repeating one section. + +## Report Title +${input.sessionTitle} + +## Full Outline +${planOutline} + +## Conclusion Plan +- Title: ${input.section.title} +- Summary: ${input.section.summary} +- Target takeaway: ${input.section.targetTakeaway} +- Citation focus: ${input.section.citationFocus.join(", ") || "(draw from the strongest whole-paper evidence)"} + +## Drafted Full Paper To Reference +${fullReference || "(No prior draft sections available.)"} + +## Relevant Chapter Packets +${relevantPacketDigest || "(No chapter packets available.)"} + +## Citation Registry +${input.citationRegistry || "(No citation registry available.)"} + +## Output Rules +- Output markdown for this section only. +- Start with a level-2 heading: \`## ${input.section.title}\` +- Reference the full paper, not just the final body section. +- The conclusion must synthesize the entire paper instead of merely repeating the last section. +- Summarize the strongest findings, remaining uncertainties, and the most justified future directions. +- Keep the conclusion tightly coupled to the logic and evidence developed across the full paper. +- Use citation keys from the relevant chapter packets when grounding concrete claims. +- ${input.preferredOutputLanguage === "zh" ? "请直接使用中文撰写。" : "Write in English."}`; + } + + return `Draft one body section for the final report. + +## Report Title +${input.sessionTitle} + +## Full Outline +${planOutline} + +## Target Section +- Title: ${input.section.title} +- Summary: ${input.section.summary} +- Target takeaway: ${input.section.targetTakeaway} +- Citation focus: ${input.section.citationFocus.join(", ") || "(use the most relevant citations for this topic)"} + +## Relevant Chapter Packets +${relevantPacketDigest || "(No chapter packets available.)"} + +## Evidence Digest +${input.artifactDigest || "(No evidence digest available.)"} + +## Citation Registry +${input.citationRegistry || "(No citation registry available.)"} + +## Output Rules +- Output markdown for this section only. +- Start with a level-2 heading: \`## ${input.section.title}\` +- Stay within this section's scope; do not write introduction or conclusion content. +- Write this as a substantial academic section, not a few summary bullets or short notes. +- Use coherent paragraphs with strong internal logical flow. +- Make the section analytically complete: define the claim, cite evidence, compare alternatives where relevant, and explain implications. +- You MUST use the available citation keys from the relevant chapter packets for concrete factual claims whenever evidence exists. +- ${input.preferredOutputLanguage === "zh" ? "请直接使用中文撰写。" : "Write in English."}`; +} + +export function buildFinalReportCoverageRevisionPrompt(input: { + sessionTitle: string; + preferredOutputLanguage: "zh" | "en"; + existingReport: string; + coverage: FinalReportCitationCoverage; + citationEntries: FinalReportCitationEntry[]; +}): string { + const missingCitations = input.coverage.missingCitationKeys.slice(0, 40).join(", "); + const referencesInstruction = input.preferredOutputLanguage === "zh" + ? "保留已有高质量内容,但扩大引文覆盖,补足参考文献与行内引用。" + : "Preserve the high-quality parts of the draft, but broaden citation coverage and expand inline citations plus the references section."; + + return `Revise the existing final report to improve citation coverage. + +## Session +- Title: ${input.sessionTitle} +- Available unique citations: ${input.coverage.availableCitationCount} +- Current cited unique citations: ${input.coverage.citedCitationCount} +- Minimum required unique citations: ${input.coverage.minimumRequiredCitationCount} +- References section present: ${input.coverage.hasReferencesSection ? "yes" : "no"} + +## Revision Goal +${referencesInstruction} + +## Existing Report +${input.existingReport} + +## Missing Citation Candidates +${missingCitations || "(No explicit missing citations listed, but the draft still needs broader coverage.)"} + +## Instructions +- Return a full revised markdown report, not partial patches or notes. +- Keep the structure coherent. +- Increase the number of distinct cited sources meaningfully. +- Add citations in sections that currently rely on too few sources. +- Ensure there is a dedicated References / 参考文献与来源线索 section. +- Do not invent citations outside the registry.`; +} + +export function analyzeFinalReportCitationCoverage( + reportText: string, + artifacts: DeepResearchArtifact[], + sessionTitle: string, + userMessages: string[], +): FinalReportCitationCoverage { + const citationEntries = buildFinalReportCitationEntries(artifacts); + const citationKeys = new Set(citationEntries.map((entry) => entry.citationKey)); + const bracketMatches = [...reportText.matchAll(/\[([^\]]+)\]/g)]; + const citedKeys = new Set<string>(); + + for (const match of bracketMatches) { + const key = match[1]?.trim(); + if (key && citationKeys.has(key)) { + citedKeys.add(key); + } + } + + const isSurveyLikeRequest = isSurveyLikeResearchRequest(sessionTitle, userMessages); + const minimumRequiredCitationCount = getMinimumRequiredCitationCount(citationEntries.length, isSurveyLikeRequest); + const hasReferencesSection = /(^|\n)#{1,6}\s*(references|reference trail|参考文献|参考文献与来源线索)\b/i.test(reportText); + const missingCitationKeys = citationEntries + .map((entry) => entry.citationKey) + .filter((key) => !citedKeys.has(key)); + + return { + availableCitationCount: citationEntries.length, + citedCitationCount: citedKeys.size, + minimumRequiredCitationCount, + hasReferencesSection, + meetsCoverage: citedKeys.size >= minimumRequiredCitationCount && hasReferencesSection, + missingCitationKeys, + }; +} + +export function buildFinalReportCitationEntries( + artifacts: DeepResearchArtifact[], +): FinalReportCitationEntry[] { + const entries: FinalReportCitationEntry[] = []; + const seen = new Set<string>(); + + for (const artifact of artifacts) { + if (artifact.artifactType !== "evidence_card" || !Array.isArray(artifact.content.sources)) { + continue; + } + + const query = typeof artifact.content.query === "string" ? artifact.content.query : artifact.title; + for (const source of artifact.content.sources) { + if (!source || typeof source !== "object") { + continue; + } + + const entry = source as Record<string, unknown>; + const title = typeof entry.title === "string" && entry.title.trim().length > 0 + ? entry.title.trim() + : null; + if (!title) { + continue; + } + + const year = typeof entry.year === "number" ? entry.year : undefined; + const citationKey = year ? `${title}, ${year}` : title; + if (seen.has(citationKey)) { + continue; + } + seen.add(citationKey); + + entries.push({ + citationKey, + title, + year, + venue: typeof entry.venue === "string" && entry.venue.trim().length > 0 ? entry.venue.trim() : undefined, + url: typeof entry.url === "string" && entry.url.trim().length > 0 ? entry.url.trim() : undefined, + doi: typeof entry.doi === "string" && entry.doi.trim().length > 0 ? entry.doi.trim() : undefined, + query, + }); + } + } + + return entries; +} + +export function getMinimumRequiredCitationCount( + availableCitationCount: number, + isSurveyLikeRequest: boolean, +): number { + if (availableCitationCount <= 0) { + return 0; + } + + const ratio = isSurveyLikeRequest ? 0.28 : 0.18; + const floor = isSurveyLikeRequest ? 12 : 8; + const cap = isSurveyLikeRequest ? 36 : 20; + return Math.min(availableCitationCount, Math.min(cap, Math.max(floor, Math.ceil(availableCitationCount * ratio)))); +} + +export function normalizeFinalReportSectionPlan(input: { + rawPlan: Record<string, unknown>; + sessionTitle: string; + preferredOutputLanguage: "zh" | "en"; + isSurveyLikeRequest: boolean; + maxBodySections?: number; +}): FinalReportSectionPlan { + const rawSections = Array.isArray(input.rawPlan.sections) ? input.rawPlan.sections : []; + const normalizedSections = rawSections + .map((section, index) => normalizeSectionPlanItem(section, index)) + .filter((section): section is FinalReportSectionPlanItem => Boolean(section)); + + const intro = normalizedSections.find((section) => section.kind === "introduction") + ?? createFallbackSection("introduction", input.preferredOutputLanguage, input.isSurveyLikeRequest); + const conclusion = normalizedSections.find((section) => section.kind === "conclusion") + ?? createFallbackSection("conclusion", input.preferredOutputLanguage, input.isSurveyLikeRequest); + const bodySections = normalizedSections.filter((section) => section.kind === "body"); + + const effectiveBodySections = bodySections.length > 0 + ? bodySections + : createFallbackBodySections(input.preferredOutputLanguage, input.isSurveyLikeRequest); + const cappedBodySections = input.maxBodySections && input.maxBodySections > 0 + ? effectiveBodySections.slice(0, input.maxBodySections) + : effectiveBodySections; + + return { + reportTitle: typeof input.rawPlan.reportTitle === "string" && input.rawPlan.reportTitle.trim().length > 0 + ? input.rawPlan.reportTitle.trim() + : input.sessionTitle, + sections: [intro, ...cappedBodySections, conclusion], + }; +} + +export function getFinalReportDraftingOrder(plan: FinalReportSectionPlan): FinalReportSectionPlanItem[] { + const intro = plan.sections.find((section) => section.kind === "introduction"); + const conclusion = plan.sections.find((section) => section.kind === "conclusion"); + const bodySections = plan.sections.filter((section) => section.kind === "body"); + + return [ + ...bodySections, + ...(intro ? [intro] : []), + ...(conclusion ? [conclusion] : []), + ]; +} + +export function getRelevantChapterPacketsForSection(input: { + section: FinalReportSectionPlanItem; + artifacts: DeepResearchArtifact[]; + limit?: number; +}): ChapterPacket[] { + const chapterPackets = extractChapterPacketsFromArtifacts(input.artifacts); + if (chapterPackets.length === 0) { + return []; + } + + return selectChapterPacketsForSection({ + sectionTitle: input.section.title, + sectionSummary: input.section.summary, + citationFocus: input.section.citationFocus, + chapterPackets, + limit: input.limit ?? 2, + }); +} + +export function assembleFinalReportFromSections(input: { + reportTitle: string; + sectionPlan: FinalReportSectionPlan; + sectionDrafts: Map<string, string>; +}): string { + const intro = input.sectionPlan.sections.find((section) => section.kind === "introduction"); + const conclusion = input.sectionPlan.sections.find((section) => section.kind === "conclusion"); + const bodySections = input.sectionPlan.sections.filter((section) => section.kind === "body"); + + const orderedSections = [ + ...(intro ? [intro] : []), + ...bodySections, + ...(conclusion ? [conclusion] : []), + ]; + + const sectionTexts = orderedSections + .map((section) => input.sectionDrafts.get(section.id)?.trim()) + .filter((text): text is string => typeof text === "string" && text.length > 0); + + return [ + `# ${input.reportTitle}`, + "", + ...sectionTexts.flatMap((text) => [text, ""]), + ].join("\n").trim(); +} + +export function extractRecognizedCitationKeys( + text: string, + citationEntries: Array<{ citationKey: string }>, +): string[] { + const knownCitationKeys = new Set(citationEntries.map((entry) => entry.citationKey)); + const citedKeys = new Set<string>(); + + for (const match of text.matchAll(/\[([^\]]+)\]/g)) { + const key = match[1]?.trim(); + if (key && knownCitationKeys.has(key)) { + citedKeys.add(key); + } + } + + return [...citedKeys]; +} + +export function buildFinalReportSectionCitationRevisionPrompt(input: { + sectionTitle: string; + preferredOutputLanguage: "zh" | "en"; + existingSection: string; + relevantPackets: ChapterPacket[]; + allowedCitationKeys: string[]; +}): string { + return `Revise this report section by inserting inline citations. + +## Section Title +${input.sectionTitle} + +## Existing Section +${input.existingSection} + +## Allowed Citation Keys +${input.allowedCitationKeys.join(", ") || "(none available)"} + +## Relevant Chapter Packets +${formatChapterPacketReferences(input.relevantPackets)} + +## Rules +- Return the full revised section only. +- Preserve the structure, logic, and level-2 heading. +- Add inline citations like [Informer, 2021] immediately after supported factual or comparative claims. +- Use only the allowed citation keys listed above. +- Do not add a references section here. +- ${input.preferredOutputLanguage === "zh" ? "请直接使用中文输出修订后的章节。" : "Write in English."}`; +} + +export function appendDeterministicReferencesSection(input: { + reportText: string; + citationEntries: FinalReportCitationEntry[]; + preferredOutputLanguage: "zh" | "en"; + fallbackCitationKeys?: string[]; + minimumReferenceCount?: number; +}): { + reportText: string; + citedCitationKeys: string[]; + referencesAdded: boolean; +} { + const citedCitationKeys = extractRecognizedCitationKeys(input.reportText, input.citationEntries); + const minimumReferenceCount = Math.max(1, input.minimumReferenceCount ?? 12); + const registryFallbackKeys = input.citationEntries + .map((entry) => entry.citationKey) + .slice(0, Math.min(Math.max(minimumReferenceCount, 8), 24)); + const keysToRender = [ + ...new Set([ + ...citedCitationKeys, + ...(input.fallbackCitationKeys ?? []), + ...registryFallbackKeys, + ]), + ].slice(0, Math.max(minimumReferenceCount, citedCitationKeys.length, 8)); + + if (keysToRender.length === 0) { + return { + reportText: input.reportText, + citedCitationKeys, + referencesAdded: false, + }; + } + + const hasReferencesSection = /(^|\n)#{1,6}\s*(references|reference trail|参考文献|参考文献与来源线索)\b/i.test(input.reportText); + if (hasReferencesSection) { + return { + reportText: input.reportText, + citedCitationKeys, + referencesAdded: false, + }; + } + + const entries = input.citationEntries.filter((entry) => keysToRender.includes(entry.citationKey)); + if (entries.length === 0) { + return { + reportText: input.reportText, + citedCitationKeys, + referencesAdded: false, + }; + } + + const heading = input.preferredOutputLanguage === "zh" + ? "## 参考文献与来源线索" + : "## References"; + const references = entries + .map((entry) => { + const metadata = [ + entry.year?.toString(), + entry.venue, + ].filter(Boolean).join(". "); + const link = entry.url + ? `[Link](${entry.url})` + : entry.doi + ? `DOI: ${entry.doi}` + : ""; + return `- [${entry.citationKey}] ${metadata ? `${metadata}. ` : ""}${link}`.trim(); + }) + .join("\n"); + + return { + reportText: `${input.reportText.trim()}\n\n${heading}\n${references}`.trim(), + citedCitationKeys, + referencesAdded: true, + }; +} + +function selectArtifactsForFinalReport( + artifacts: DeepResearchArtifact[], + options?: { + digestMode?: FinalReportDigestMode; + preferredArtifactIds?: string[]; + }, +): DeepResearchArtifact[] { + const preferredTypes = new Set([ + "structured_summary", + "provisional_conclusion", + "review_assessment", + "reviewer_packet", + "evidence_card", + "validation_report", + "experiment_result", + "step_result", + "literature_round_summary", + "claim_map", + ]); + + const selected = artifacts + .filter((artifact) => preferredTypes.has(artifact.artifactType)) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); + const preferredArtifactIdSet = new Set(options?.preferredArtifactIds ?? []); + const preferredArtifacts = selected.filter((artifact) => preferredArtifactIdSet.has(artifact.id)); + const nonPreferredArtifacts = selected.filter((artifact) => !preferredArtifactIdSet.has(artifact.id)); + + const digestMode = options?.digestMode ?? "standard"; + const nonEvidenceLimit = digestMode === "compact" ? 6 : 8; + const evidenceLimit = digestMode === "compact" ? 4 : 8; + const nonEvidence = nonPreferredArtifacts.filter((artifact) => artifact.artifactType !== "evidence_card"); + const evidence = nonPreferredArtifacts.filter((artifact) => artifact.artifactType === "evidence_card").slice(-evidenceLimit); + + const merged: DeepResearchArtifact[] = []; + const seen = new Set<string>(); + + for (const artifact of [...preferredArtifacts, ...nonEvidence.slice(-nonEvidenceLimit), ...evidence]) { + if (seen.has(artifact.id)) { + continue; + } + seen.add(artifact.id); + merged.push(artifact); + } + + return merged; +} + +function normalizeSectionPlanItem(rawSection: unknown, index: number): FinalReportSectionPlanItem | null { + if (!rawSection || typeof rawSection !== "object") { + return null; + } + + const record = rawSection as Record<string, unknown>; + const title = typeof record.title === "string" && record.title.trim().length > 0 + ? record.title.trim() + : null; + if (!title) { + return null; + } + + const explicitKind = typeof record.kind === "string" ? record.kind.trim().toLowerCase() : ""; + const normalizedTitle = title.toLowerCase(); + const kind = explicitKind === "introduction" || /^(introduction|intro|引言|导论)/i.test(title) + ? "introduction" + : explicitKind === "conclusion" || /^(conclusion|结论|总结)/i.test(title) + ? "conclusion" + : "body"; + + const summary = typeof record.summary === "string" && record.summary.trim().length > 0 + ? record.summary.trim() + : typeof record.objective === "string" && record.objective.trim().length > 0 + ? record.objective.trim() + : `Cover the main content of ${normalizedTitle}.`; + const targetTakeaway = typeof record.targetTakeaway === "string" && record.targetTakeaway.trim().length > 0 + ? record.targetTakeaway.trim() + : typeof record.conclusion === "string" && record.conclusion.trim().length > 0 + ? record.conclusion.trim() + : summary; + const citationFocus = Array.isArray(record.citationFocus) + ? record.citationFocus.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; + + return { + id: typeof record.id === "string" && record.id.trim().length > 0 ? record.id.trim() : `section_${index + 1}`, + title, + kind, + summary, + targetTakeaway, + citationFocus, + }; +} + +function createFallbackSection( + kind: "introduction" | "conclusion", + preferredOutputLanguage: "zh" | "en", + isSurveyLikeRequest: boolean, +): FinalReportSectionPlanItem { + if (kind === "introduction") { + return preferredOutputLanguage === "zh" + ? { + id: "section_intro", + title: "引言", + kind: "introduction", + summary: isSurveyLikeRequest ? "说明问题背景、研究范围、综述组织方式与核心技术路线。" : "说明问题背景、目标和文章结构。", + targetTakeaway: "让读者理解本文的研究对象、范围和后续组织逻辑。", + citationFocus: ["background", "scope", "taxonomy framing"], + } + : { + id: "section_intro", + title: "Introduction", + kind: "introduction", + summary: isSurveyLikeRequest ? "Introduce the problem setting, scope of the survey, and the major technical routes." : "Introduce the problem setting, objective, and paper structure.", + targetTakeaway: "Orient the reader to the scope and organizing logic of the paper.", + citationFocus: ["background", "scope", "taxonomy framing"], + }; + } + + return preferredOutputLanguage === "zh" + ? { + id: "section_conclusion", + title: "结论", + kind: "conclusion", + summary: "综合全文发现,概括主结论、不确定性与未来工作方向。", + targetTakeaway: "让读者明确什么结论最稳健、哪些问题仍未解决。", + citationFocus: ["full-paper synthesis", "open problems"], + } + : { + id: "section_conclusion", + title: "Conclusion", + kind: "conclusion", + summary: "Synthesize the paper's findings, limitations, and future directions.", + targetTakeaway: "Clarify what is most supported, what remains uncertain, and what comes next.", + citationFocus: ["full-paper synthesis", "open problems"], + }; +} + +function createFallbackBodySections( + preferredOutputLanguage: "zh" | "en", + isSurveyLikeRequest: boolean, +): FinalReportSectionPlanItem[] { + if (preferredOutputLanguage === "zh") { + return isSurveyLikeRequest + ? [ + { + id: "section_body_1", + title: "任务背景与问题定义", + kind: "body", + summary: "界定任务、输入输出形式与时间序列 Transformer 研究问题。", + targetTakeaway: "明确综述对象和分析边界。", + citationFocus: ["background", "problem setting"], + }, + { + id: "section_body_2", + title: "架构谱系与技术路线", + kind: "body", + summary: "按主要技术路线梳理时间序列 Transformer 的架构分类。", + targetTakeaway: "给出结构化 taxonomy。", + citationFocus: ["taxonomy", "architecture families"], + }, + { + id: "section_body_3", + title: "代表模型与关键设计选择", + kind: "body", + summary: "比较代表模型、关键模块设计与建模取舍。", + targetTakeaway: "解释不同模型为什么有效、差异在哪里。", + citationFocus: ["representative methods", "design trade-offs"], + }, + { + id: "section_body_4", + title: "实验规律、局限与开放问题", + kind: "body", + summary: "总结经验规律、局限性和未来值得研究的问题。", + targetTakeaway: "给出证据支持下的批判性判断。", + citationFocus: ["benchmarks", "limitations", "open problems"], + }, + ] + : [ + { + id: "section_body_1", + title: "核心发现", + kind: "body", + summary: "总结主要证据和发现。", + targetTakeaway: "突出最重要的研究结论。", + citationFocus: ["main findings"], + }, + { + id: "section_body_2", + title: "对比分析与局限", + kind: "body", + summary: "比较不同方法并解释局限。", + targetTakeaway: "说明证据的边界和不确定性。", + citationFocus: ["comparative analysis", "limitations"], + }, + ]; + } + + return isSurveyLikeRequest + ? [ + { + id: "section_body_1", + title: "Problem Setting And Background", + kind: "body", + summary: "Define the task setting, inputs/outputs, and the core problem addressed by time-series Transformers.", + targetTakeaway: "Clarify the survey's scope and analytical boundary.", + citationFocus: ["background", "problem setting"], + }, + { + id: "section_body_2", + title: "Architecture Taxonomy And Technical Routes", + kind: "body", + summary: "Organize the main architecture families and technical routes in time-series Transformers.", + targetTakeaway: "Provide a structured taxonomy of the field.", + citationFocus: ["taxonomy", "architecture families"], + }, + { + id: "section_body_3", + title: "Representative Models And Design Trade-offs", + kind: "body", + summary: "Compare representative models, core modules, and design choices.", + targetTakeaway: "Explain where the main methods differ and why those differences matter.", + citationFocus: ["representative methods", "design trade-offs"], + }, + { + id: "section_body_4", + title: "Empirical Patterns, Limitations, And Open Problems", + kind: "body", + summary: "Summarize benchmark patterns, limitations, and open research questions.", + targetTakeaway: "Ground the survey in evidence-backed strengths, weaknesses, and future directions.", + citationFocus: ["benchmarks", "limitations", "open problems"], + }, + ] + : [ + { + id: "section_body_1", + title: "Main Findings", + kind: "body", + summary: "Summarize the main evidence and findings.", + targetTakeaway: "Highlight the most important conclusions.", + citationFocus: ["main findings"], + }, + { + id: "section_body_2", + title: "Comparative Analysis And Limitations", + kind: "body", + summary: "Compare methods and explain limitations.", + targetTakeaway: "Clarify the boundary conditions of the evidence.", + citationFocus: ["comparative analysis", "limitations"], + }, + ]; +} + +function formatArtifactForFinalReport( + artifact: DeepResearchArtifact, + options?: { digestMode?: FinalReportDigestMode }, +): string { + const digestMode = options?.digestMode ?? "standard"; + const previewLimit = digestMode === "compact" ? 500 : 900; + const evidenceSourceLimit = digestMode === "compact" ? 3 : 6; + + if (artifact.artifactType === "evidence_card") { + const query = typeof artifact.content.query === "string" ? artifact.content.query : artifact.title; + const coverageSummary = typeof artifact.content.coverageSummary === "string" + ? truncateText(artifact.content.coverageSummary, digestMode === "compact" ? 180 : 260) + : ""; + const sources = Array.isArray(artifact.content.sources) + ? artifact.content.sources + .filter((source): source is Record<string, unknown> => Boolean(source) && typeof source === "object") + .slice(0, evidenceSourceLimit) + .map((source) => { + const title = typeof source.title === "string" ? source.title : "Untitled source"; + const year = typeof source.year === "number" ? ` (${source.year})` : ""; + const relevance = typeof source.relevance === "string" ? ` - ${source.relevance}` : ""; + return ` - ${truncateText(title, 120)}${year}${truncateText(relevance, 80)}`; + }) + .join("\n") + : ""; + + return [ + `### ${artifact.title} [${artifact.artifactType}]`, + `- Query: ${query}`, + typeof artifact.content.sourcesFound === "number" ? `- Sources found: ${artifact.content.sourcesFound}` : null, + coverageSummary ? `- Coverage: ${coverageSummary}` : null, + sources ? `- Representative sources:\n${sources}` : null, + ].filter((line): line is string => Boolean(line)).join("\n"); + } + + if (artifact.artifactType === "review_assessment") { + return [ + `### ${artifact.title} [${artifact.artifactType}]`, + `- Verdict: ${String(artifact.content.combinedVerdict ?? "unknown")}`, + `- Confidence: ${String(artifact.content.combinedConfidence ?? "unknown")}`, + Array.isArray(artifact.content.openIssues) && artifact.content.openIssues.length > 0 + ? `- Open issues: ${(artifact.content.openIssues as string[]).join("; ")}` + : null, + Array.isArray(artifact.content.literatureGaps) && artifact.content.literatureGaps.length > 0 + ? `- Literature gaps: ${(artifact.content.literatureGaps as string[]).join("; ")}` + : null, + typeof artifact.content.reviewerSummary === "string" ? `- Summary: ${artifact.content.reviewerSummary}` : null, + ].filter((line): line is string => Boolean(line)).join("\n"); + } + + if (artifact.artifactType === "structured_summary" && Array.isArray(artifact.content.chapterPackets)) { + const chapterPackets = extractChapterPacketsFromArtifacts([artifact]).slice(0, digestMode === "compact" ? 2 : 4); + const chapterDigest = chapterPackets + .map((packet) => [ + `#### ${packet.title}`, + `- Summary: ${truncateText(packet.summary, digestMode === "compact" ? 160 : 240)}`, + packet.citationKeys.length > 0 ? `- Citation keys: ${packet.citationKeys.slice(0, 6).join(", ")}` : null, + packet.keyTakeaways.length > 0 ? `- Takeaways: ${packet.keyTakeaways.slice(0, 4).join("; ")}` : null, + packet.recommendedSectionText + ? `- Section seed: ${truncateText(packet.recommendedSectionText, digestMode === "compact" ? 220 : 360)}` + : null, + ].filter((line): line is string => Boolean(line)).join("\n")) + .join("\n\n"); + + return [ + `### ${artifact.title} [${artifact.artifactType}]`, + typeof artifact.content.summary === "string" ? truncateText(artifact.content.summary, previewLimit) : null, + chapterDigest, + ].filter((line): line is string => Boolean(line)).join("\n"); + } + + const preferredText = [ + artifact.content.report, + artifact.content.summary, + artifact.content.messageToUser, + artifact.content.content, + artifact.content.text, + artifact.content.currentFindings, + ].find((value): value is string => typeof value === "string" && value.trim().length > 0); + + const preview = preferredText + ? truncateText(preferredText, previewLimit) + : truncateText(JSON.stringify(artifact.content, null, 2), previewLimit); + + return `### ${artifact.title} [${artifact.artifactType}]\n${preview}`; +} + +function buildCitationRegistry(entries: FinalReportCitationEntry[], maxEntries = 40): string { + return entries + .slice(0, maxEntries) + .map((entry) => [ + `- [${entry.citationKey}]`, + entry.venue ? `venue=${entry.venue}` : null, + entry.url ? `url=${entry.url}` : null, + entry.doi ? `doi=${entry.doi}` : null, + `query=${entry.query}`, + ].filter((item): item is string => Boolean(item)).join(" | ")) + .join("\n"); +} + +function assessFinalReportReadiness(input: { + artifacts: DeepResearchArtifact[]; + relevantArtifacts: DeepResearchArtifact[]; + citationEntries: FinalReportCitationEntry[]; + isSurveyLikeRequest: boolean; +}): FinalReportReadiness { + const evidenceCardCount = input.artifacts.filter((artifact) => artifact.artifactType === "evidence_card").length; + const synthesisArtifactCount = input.artifacts.filter((artifact) => + artifact.artifactType === "structured_summary" + || artifact.artifactType === "provisional_conclusion" + || artifact.artifactType === "review_assessment" + || artifact.artifactType === "reviewer_packet" + || artifact.artifactType === "literature_round_summary" + || artifact.artifactType === "claim_map" + || artifact.artifactType === "validation_report" + ).length; + const totalSourceCount = estimateTotalSourceCount(input.artifacts); + const availableCitationCount = input.citationEntries.length; + + let status: FinalReportReadiness["status"] = "ready"; + let canDraft = true; + let recommendedAction = "Proceed with final report drafting."; + + if (totalSourceCount === 0 && synthesisArtifactCount === 0) { + status = "insufficient_evidence"; + canDraft = false; + recommendedAction = "Add a targeted evidence or synthesis pass before retrying the final report."; + } else if ( + input.isSurveyLikeRequest + ? totalSourceCount < 4 && availableCitationCount < 4 && synthesisArtifactCount === 0 + : totalSourceCount < 2 && synthesisArtifactCount === 0 + ) { + status = "insufficient_evidence"; + canDraft = false; + recommendedAction = "Collect more targeted evidence for the uncovered subtopics before another final-report attempt."; + } else if ( + input.isSurveyLikeRequest + ? totalSourceCount < 8 || availableCitationCount < 6 + : totalSourceCount < 4 || availableCitationCount < 2 + ) { + status = "thin_evidence"; + recommendedAction = "The report can be drafted, but a targeted supplement for weakly covered subtopics would improve reliability."; + } + + return { + canDraft, + status, + summary: `Final-report readiness: ${status}. Selected ${input.relevantArtifacts.length}/${input.artifacts.length} relevant artifacts, ${evidenceCardCount} evidence cards, ${synthesisArtifactCount} synthesis artifacts, ${availableCitationCount} citations, ${totalSourceCount} total source signals.`, + recommendedAction, + totalRelevantArtifactCount: input.artifacts.length, + selectedArtifactCount: input.relevantArtifacts.length, + evidenceCardCount, + synthesisArtifactCount, + availableCitationCount, + totalSourceCount, + }; +} + +function estimateTotalSourceCount(artifacts: DeepResearchArtifact[]): number { + return artifacts.reduce((sum, artifact) => { + if (artifact.artifactType !== "evidence_card") { + return sum; + } + + const sources = Array.isArray(artifact.content.sources) ? artifact.content.sources : []; + const explicitCount = [ + artifact.content.sourcesFound, + artifact.content.totalFound, + artifact.content.papersFound, + ].find((value): value is number => typeof value === "number" && Number.isFinite(value)); + + return sum + Math.max(explicitCount ?? 0, sources.length); + }, 0); +} + +function formatDraftedSectionReferences( + sections: Array<{ title: string; content: string }>, + excerptLimit: number, +): string { + return sections + .filter((section) => section.content.trim().length > 0) + .map((section) => `### ${section.title}\n${truncateText(section.content.trim(), excerptLimit)}`) + .join("\n\n"); +} + +function formatChapterPacketReferences(chapterPackets: ChapterPacket[]): string { + return chapterPackets + .map((packet) => [ + `### ${packet.title}`, + `- Objective: ${packet.objective}`, + `- Summary: ${packet.summary}`, + packet.keyTakeaways.length > 0 ? `- Key takeaways: ${packet.keyTakeaways.join("; ")}` : null, + packet.citationKeys.length > 0 ? `- Citation keys: ${packet.citationKeys.join(", ")}` : null, + packet.supportingQuotes.length > 0 + ? `- Supporting quotes: ${packet.supportingQuotes.slice(0, 3).map((quote) => `${quote.sourceTitle}: ${truncateText(quote.quote, 120)} [${quote.citationKey}]`).join(" | ")}` + : null, + packet.recommendedSectionText ? `- Section seed: ${truncateText(packet.recommendedSectionText, 420)}` : null, + ].filter((line): line is string => Boolean(line)).join("\n")) + .join("\n\n"); +} + +function truncateText(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + + return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} diff --git a/src/lib/deep-research/prompt-builders/main-brain-prompt.ts b/src/lib/deep-research/prompt-builders/main-brain-prompt.ts new file mode 100644 index 00000000..31f57203 --- /dev/null +++ b/src/lib/deep-research/prompt-builders/main-brain-prompt.ts @@ -0,0 +1,192 @@ +import type { + ContextTag, + DeepResearchArtifact, + DeepResearchMessage, + DeepResearchNode, + DeepResearchSession, + RequirementState, +} from "../types"; +import { + buildRuntimeRoleContract, + listMetaWorkerRoleDefinitions, +} from "../role-registry"; + +export function buildMainBrainSystemPrompt( + session: DeepResearchSession, + messages: DeepResearchMessage[], + nodes: DeepResearchNode[], + artifacts: DeepResearchArtifact[], + contextTag: ContextTag, + requirementState?: RequirementState | null, + workstationContext?: string | null, + memoryContext?: string | null, + doctrineContext?: string | null, +): string { + const specialistRoleNames = listMetaWorkerRoleDefinitions().map((role) => role.roleName).join(", "); + const researcherContract = buildRuntimeRoleContract("researcher", "plan", { + includeResponsibilities: true, + includeCollaboration: true, + includePerformance: true, + maxItemsPerSection: 3, + }); + const nodeStatusSummary = nodes.map((node) => + ` - [short=${node.id.slice(0, 8)} | id=${node.id}] ${node.label} (${node.nodeType}, ${node.status}, role=${node.assignedRole}, context=${node.contextTag})`, + ).join("\n"); + + const artifactSummary = artifacts + .filter((artifact) => !artifact.artifactType.startsWith("memory_")) + .map((artifact) => { + const contentString = JSON.stringify(artifact.content); + const preview = contentString.length > 500 ? `${contentString.slice(0, 500)}...` : contentString; + return ` - [short=${artifact.id.slice(0, 8)} | id=${artifact.id}] ${artifact.title} (${artifact.artifactType}): ${preview}`; + }).join("\n"); + + const recentMessages = messages.slice(-10).map((message) => + ` [${message.role}]: ${message.content.slice(0, 300)}${message.content.length > 300 ? "..." : ""}`, + ).join("\n"); + + const reviewerPackets = artifacts + .filter((artifact) => artifact.artifactType === "reviewer_packet") + .map((artifact) => ` Reviewer Packet [short=${artifact.id.slice(0, 8)} | id=${artifact.id}]: ${JSON.stringify(artifact.content).slice(0, 500)}`) + .join("\n"); + + const reviewAssessments = artifacts + .filter((artifact) => artifact.artifactType === "review_assessment") + .map((artifact) => ` Review Assessment [short=${artifact.id.slice(0, 8)} | id=${artifact.id}]: ${JSON.stringify(artifact.content).slice(0, 500)}`) + .join("\n"); + + let requirementSection = ""; + if (requirementState && requirementState.requirements.length > 0) { + const requirementLines = requirementState.requirements.map((requirement) => + ` - [${requirement.status}] (${requirement.priority}) ${requirement.text}${requirement.satisfiedByNodeIds.length > 0 ? ` [satisfied by: ${requirement.satisfiedByNodeIds.join(",")}]` : ""}`, + ).join("\n"); + const constraintLines = requirementState.constraints.map((constraint) => + ` - [${constraint.status}] (${constraint.type}) ${constraint.text}: ${constraint.value}`, + ).join("\n"); + requirementSection = ` +## Requirements (v${requirementState.version}) +IMPORTANT: Check each requirement's status before planning. Only create work for ACTIVE requirements. +${requirementLines} + +## Constraints +${constraintLines} +`; + } + + const workstationSection = workstationContext + ? `\n## Additional Coordination Context\n${workstationContext}\n` + : ""; + const memorySection = memoryContext ? `\n${memoryContext}\n` : ""; + const doctrineSection = doctrineContext ? `\n${doctrineContext}\n` : ""; + + return `You are GPT-5.4 High acting as the "Researcher" — the main-brain of an automated research tool. + +## Core Mission +Lead the entire automated research workflow, make data-driven decisions, and keep the research process logical, rigorous, traceable, and aligned with the user's goals. + +## Structured Role Contract +${researcherContract || " (no structured role contract available)"} + +## Specialist Topology +- Researcher (you) is the top-level coordinator and decision-maker. +- Results and Evidence Analyst reviews are advisory only. They cannot redefine the workflow. +- Specialist roles available for dispatch: ${specialistRoleNames}. + +## Runtime Dispatch Rules +- Return at most ONE NodeCreationSpec in "nodesToCreate" for each decision. +- One clear task per node, with explicit inputs, outputs, stop conditions, and dependencies. +- Assign exactly one responsible role per node and keep assignments non-overlapping. +- Runtime dispatch is node-driven, not stage-driven. Decide the next worker from the confirmed plan, current evidence, and dependency state. +- Do not rely on hidden workflow stages. If a worker should act next, express that directly in "nodesToCreate". +- When you copy a node/artifact reference into "dependsOn", "targetArtifactIds", or "sourceArtifactIds", use the full canonical id shown as \`id=...\`, not just the short display prefix. + +## Ambiguity And Planning Gates +- On the first planning pass, you MUST use "messageToUser" to present a complete plan grounded in the workstation search before any worker dispatch can begin. +- The first plan must explain: objectives, task split, phases to execute, phases to skip, expected outputs, risks, and why the chosen workflow fits the user's question. +- Treat "nodesToCreate" as a proposed assignment set until the user confirms. +- If ambiguity remains, return action "respond_to_user" and ask targeted questions with no worker dispatch. +- When the next step is already clear and within confirmed scope, you may move directly into execution supervision and dispatch role-scoped tasks. +- Do NOT schedule experiment design or execution just because those roles exist. Use them only when the user explicitly asks for empirical validation, implementation, reproduction, benchmarking, or when the confirmed plan truly requires it. +- For survey, mechanism, taxonomy, comparison, conceptual-analysis, and desk-research requests, default to literature/synthesis/reporting workflows and explicitly skip unnecessary experimental phases. +- Any plan you present should explicitly cover objectives, task division, time nodes, resource requirements, risk prevention, and the four verification checks. + +## Current State +- Session: "${session.title}" (id: ${session.id}) +- Status: ${session.status} +- Legacy Context Tag: ${contextTag} (compatibility only; do not treat this as a required next step) +- Literature round: ${session.literatureRound} / ${session.config.literature.maxLiteratureRounds} +- Reviewer round: ${session.reviewerRound} / ${session.config.maxReviewerRounds} +- Execution loop: ${session.executionLoop} / ${session.config.maxExecutionLoops} +- Budget: ${session.budget.totalTokens} / ${session.config.budget.maxTotalTokens} total tokens +- Opus tokens: ${session.budget.opusTokens} / ${session.config.budget.maxOpusTokens} + +## Task Graph Nodes +${nodeStatusSummary || " (none yet)"} + +## Artifacts +${artifactSummary || " (none yet)"} + +## Analytical Critique Feedback +${reviewerPackets || " (none yet)"} + +## Review Assessments +${reviewAssessments || " (none yet)"} + +## Recent Conversation +${recentMessages || " (no messages yet)"} +${requirementSection} +${workstationSection} +${doctrineSection} +${memorySection} +## Output Format +You MUST respond with valid JSON matching the BrainDecision schema: +{ + "action": "advance_context" | "revise_plan" | "request_approval" | "complete" | "respond_to_user", + "nextContextTag": "(optional legacy context tag for compatibility only)", + "nodesToCreate": [(optional) array of NodeCreationSpec], + "messageToUser": "(optional) message to display", + "reasoning": "(optional) internal reasoning" +} + +NodeCreationSpec: +{ + "nodeType": "evidence_gather|evidence_extract|summarize|synthesize|review|audit|validation_plan|resource_request|execute|monitor|result_collect|result_compare|approve|final_report", + "label": "specific task description", + "assignedRole": "researcher|literature_intelligence_analyst|experiment_architecture_designer|research_software_engineer|experiment_operations_engineer|results_and_evidence_analyst|research_asset_reuse_specialist", + "input": { ... task-specific input ... }, + "dependsOn": ["nodeId1"], + "parentId": "optional parent", + "contextTag": "optional legacy context tag; omit unless needed for compatibility" +} + +## Recommended "messageToUser" Structure For Planning Or Clarification +When you choose to present a plan or clarification request, prefer this structure: +1. Context Review Summary +2. Workstation Search Findings +3. Clarification Questions (only if ambiguity exists) +4. Plan Options +5. Recommended Plan +6. Verification Statement +7. User Confirmation Request + +Inside Recommended Plan, include: +- 1. Core Research Objectives +- 2. Task Division & Responsible Roles +- 3. Time Nodes +- 4. Resource Requirements +- 5. Risk Prevention + +Inside Plan Options, provide 2-3 concrete options when meaningful, each with: +- Scope and intended outcome +- Trade-offs +- Required roles/resources +- Why the user might choose it + +The verification statement must explicitly cover alignment, feasibility, rigor, and completeness. + +## Cost Awareness +- Use specialist roles for bulk work and keep your own reasoning focused on strategic decisions. +- Max specialist fan-out: 1 +- Specialist execution is serial. Only one specialist task runs at a time. +- Literature bounds: max ${session.config.literature.maxPapersPerRound} papers/round, max ${session.config.literature.maxLiteratureRounds} rounds`; +} diff --git a/src/lib/deep-research/prompt-builders/review-prompt.ts b/src/lib/deep-research/prompt-builders/review-prompt.ts new file mode 100644 index 00000000..a6b6dc34 --- /dev/null +++ b/src/lib/deep-research/prompt-builders/review-prompt.ts @@ -0,0 +1,57 @@ +import type { DeepResearchArtifact } from "../types"; + +export function buildReviewerSystemPrompt( + role: "results_and_evidence_analyst", + targetArtifacts: DeepResearchArtifact[], + previousPackets?: DeepResearchArtifact[], + roundInfo?: { round: number; maxRounds: number }, +): string { + const artifactsSection = targetArtifacts.map((artifact) => + `### ${artifact.title} (${artifact.artifactType})\n${JSON.stringify(artifact.content, null, 2)}` + ).join("\n\n"); + + const previousSection = previousPackets && previousPackets.length > 0 + ? "\n## Previous Review Rounds\n" + previousPackets.map((packet) => + `### ${packet.title}\n${JSON.stringify(packet.content, null, 2)}` + ).join("\n\n") + : ""; + + const roleLabel = "Results and Evidence Analyst"; + const roundLabel = roundInfo ? ` (Round ${roundInfo.round} of ${roundInfo.maxRounds})` : ""; + + return `You are ${roleLabel} in a Deep Research review process${roundLabel}. + +## YOUR ROLE AND LIMITS +- You CRITIQUE the Researcher's synthesis. You provide advisory feedback. +- You CANNOT dispatch workers, search for papers, or run experiments. +- You CANNOT modify the workflow graph. +- You CANNOT override the Researcher's decisions. +- You CAN identify specific gaps that need more literature. +- You CAN suggest specific experiments that would validate claims. +- Your recommendations go to the Researcher, who decides whether to act on them. + +## Artifacts to Review +${artifactsSection} +${previousSection} + +## Output Format +Respond with valid JSON: +{ + "reviewerRole": "${role}", + "verdict": "approve|revise|reject", + "critique": "Detailed critique — be specific, cite evidence", + "suggestions": ["Actionable suggestion 1", "Suggestion 2"], + "confidence": 0.0-1.0, + "identifiedGaps": ["Specific literature gaps found — be precise about what's missing"], + "needsExperimentalValidation": true/false, + "suggestedExperiments": ["Specific experiment if validation needed"] +} + +## Guidelines +- Be specific. "The analysis is weak" is useless. "The claim about X lacks supporting evidence from controlled experiments" is useful. +- Identify: logical gaps, unsupported claims, missing baselines, methodology issues, novelty issues. +- For literature gaps: state EXACTLY what is missing (e.g., "Missing comparison against method Y on dataset Z"). +- "revise" = has promise, needs specific improvements. +- "approve" = meets research standard. +- "reject" = fundamental issues.`; +} diff --git a/src/lib/deep-research/prompt-builders/worker-prompts.ts b/src/lib/deep-research/prompt-builders/worker-prompts.ts new file mode 100644 index 00000000..6db0e3ec --- /dev/null +++ b/src/lib/deep-research/prompt-builders/worker-prompts.ts @@ -0,0 +1,279 @@ +import type { + DeepResearchArtifact, + DeepResearchNode, + DeepResearchSession, + NodeType, + ReviewAssessment, +} from "../types"; +import { buildRuntimeRoleContract } from "../role-registry"; + +export function buildWorkerSystemPrompt( + node: DeepResearchNode, + parentArtifacts: DeepResearchArtifact[], + taskType: NodeType, +): string { + const roleContract = buildRuntimeRoleContract(node.assignedRole, taskType, { + includeResponsibilities: true, + includeCollaboration: true, + includePerformance: true, + maxItemsPerSection: 2, + }); + const contextSection = parentArtifacts.length > 0 + ? "## Context Artifacts\n" + parentArtifacts.map((artifact) => + `### ${artifact.title} (${artifact.artifactType})\n${JSON.stringify(artifact.content, null, 2)}` + ).join("\n\n") + : ""; + + const outputSchema = getWorkerOutputSchema(taskType); + + return `You are a specialist role executing a specific, scoped subtask. + +## RULES +- Focus ONLY on the assigned task. Do NOT address the broader research question. +- Cite provenance for all claims: which source, which section, what evidence. +- Do NOT hallucinate. If information is missing, say so. +- Do NOT self-assign additional tasks or redefine the plan. +- Do NOT dispatch other roles or make final conclusions. +- Be thorough but concise. Quality over quantity. + +## Structured Role Contract +${roleContract || " (no structured role contract available)"} + +## Your Task +${node.label} + +## Task Input +${node.input ? JSON.stringify(node.input, null, 2) : "(no specific input)"} + +${contextSection} + +## Output Requirements +${outputSchema}`; +} + +export function buildEvidenceGatherPrompt( + query: string, + constraints?: { maxSources?: number; focusAreas?: string[] }, +): string { + const maxSources = constraints?.maxSources ?? 10; + return `Search for and gather evidence related to: + +## Query +${query} + +## Constraints +- Maximum sources to find: ${maxSources} +- Focus areas: ${constraints?.focusAreas?.join(", ") || "none specified — use your judgment"} + +## Instructions + +**You MUST use the searchArticles tool to find papers.** Do not skip tool usage. + +1. First, call searchArticles with broad keywords extracted from the query (2-4 keywords). +2. If the first search returns few results, try again with different/broader keywords. +3. Try variations: synonyms, related terms, shorter keyword lists. +4. Search both arXiv and Hugging Face sources. + +For each source found, extract: +1. Source (paper title, URL) +2. Relevant findings or excerpts +3. Methodology used +4. Confidence in evidence quality (high/medium/low) +5. How it relates to the query + +After searching, respond with a JSON object: +\`\`\`json +{ + "sources": [{ "title": "...", "url": "...", "findings": "...", "methodology": "...", "confidence": "high|medium|low", "relevance": "..." }], + "totalFound": <number>, + "searchQueries": ["keywords used..."], + "coverageSummary": "Brief description of what was found" +} +\`\`\` + +Be systematic. Cover the query from multiple angles if possible. +STOP when you have found ${maxSources} relevant sources or exhausted available search results. +Do NOT do unbounded searching. Quality over quantity.`; +} + +export function buildValidationPlanPrompt( + session: DeepResearchSession, + synthesisArtifacts: DeepResearchArtifact[], + reviewAssessment: ReviewAssessment | null, +): string { + const synthesisSection = synthesisArtifacts.map((artifact) => + `### ${artifact.title}\n${JSON.stringify(artifact.content, null, 2)}` + ).join("\n\n"); + + const reviewSection = reviewAssessment + ? `## Reviewer Outcome\n${JSON.stringify(reviewAssessment, null, 2)}` + : ""; + + return `Convert the research findings into a concrete validation plan. + +## Research Findings +${synthesisSection} + +${reviewSection} + +## User's Original Question +${session.title} + +## Instructions +Produce a validation plan as JSON: +{ + "objective": "What we are trying to validate", + "hypothesis": "The specific hypothesis to test", + "literaturePrediction": "What literature suggests should happen", + "requiredResources": {"gpu": N, "memoryMb": N, "cpu": N, "privateMachine": "yes|no|group"}, + "datasets": ["dataset1", "dataset2"], + "steps": [ + { + "stepNumber": 1, + "description": "What this step does", + "command": "command to run (if applicable)", + "scriptPath": "path/to/script (if applicable)", + "launcherType": "rjob|rlaunch|local_shell", + "requiresApproval": true, + "expectedDuration": "estimate" + } + ], + "expectedOutputs": ["metric1", "metric2"], + "failureCriteria": ["condition that means the hypothesis is false"], + "successCriteria": ["condition that confirms the hypothesis"] +} + +Be specific and executable. Each step should be doable by the appropriate specialist role.`; +} + +function getWorkerOutputSchema(taskType: NodeType): string { + switch (taskType) { + case "evidence_gather": + return `Produce an evidence card as JSON: +{ + "claims": [{"claim": "...", "evidence": "...", "source": "...", "confidence": "high|medium|low"}], + "methods": ["methods identified"], + "datasets": ["datasets mentioned"], + "gaps": ["areas where evidence is insufficient"], + "papersFound": 0, + "searchQueries": ["queries used"], + "confidence": 0.0-1.0 +} +Stay within the specified paper limit. Do not do unbounded searching.`; + + case "evidence_extract": + return `Extract structured information from the provided papers/sources: +{ + "extractions": [ + {"source": "...", "objective": "...", "method": "...", "results": "...", "limitations": "..."} + ], + "crossReferences": ["connections between sources"], + "confidence": 0.0-1.0 +}`; + + case "execute": + return `Produce a step result as JSON: +{ + "status": "success|failure|partial", + "outputs": { ... }, + "commands": ["commands executed"], + "observations": ["key observations"], + "errors": ["any errors"], + "metrics": { ... } +}`; + + case "resource_request": + return `Produce a resource request manifest as JSON: +{ + "launcherType": "rlaunch|rjob", + "resources": {"gpu": N, "memoryMb": N, "cpu": N}, + "purpose": "what this resource is for", + "estimatedDuration": "estimate", + "manifest": { ... full manifest fields ... } +}`; + + case "monitor": + return `Produce a monitoring report as JSON: +{ + "jobStatus": "running|completed|failed|unknown", + "progress": "description of progress", + "metrics": { ... }, + "issues": ["any issues observed"], + "estimatedCompletion": "estimate" +}`; + + case "result_collect": + return `Collect and package results as JSON: +{ + "outputs": { ... collected files/metrics ... }, + "summary": "brief summary of results", + "completeness": "complete|partial|failed", + "missingOutputs": ["expected outputs not found"] +}`; + + case "result_compare": + return `Compare results against expectations as JSON: +{ + "hypothesis": "what was expected", + "actualResult": "what happened", + "match": "confirmed|partially_confirmed|contradicted|inconclusive", + "metrics": { ... }, + "analysis": "detailed comparison", + "confidence": 0.0-1.0 +}`; + + case "summarize": + return `Produce a structured summary as JSON: +{ + "summary": "overall synthesis paragraph", + "chapterPackets": [ + { + "id": "chapter_1", + "title": "section-ready chapter title", + "objective": "what this chapter should establish", + "summary": "2-4 sentence synthesis for this chapter", + "keyTakeaways": ["takeaway 1", "takeaway 2"], + "claims": [ + { + "id": "claim_1", + "text": "specific evidence-backed claim", + "strength": "strong|moderate|weak|unsupported", + "citationKeys": ["Informer, 2021"], + "supportingSourceTitles": ["Informer"], + "counterpoints": ["optional caveat"] + } + ], + "supportingQuotes": [ + { + "citationKey": "Informer, 2021", + "sourceTitle": "Informer", + "quote": "short excerpt or precise evidence statement", + "relevance": "why it matters for this chapter" + } + ], + "citationKeys": ["Informer, 2021", "Autoformer, 2021"], + "openQuestions": ["remaining uncertainty"], + "recommendedSectionText": "draft-quality section seed text with inline citations like [Informer, 2021]." + } + ], + "crossSectionThemes": ["theme 1"], + "globalOpenQuestions": ["question 1"], + "recommendedReportNarrative": "how the final report should connect the chapter packets" +} +Rules: +- Build chapterPackets that can be consumed directly by a final-report writer. +- Every non-trivial chapter should carry explicit citationKeys. +- recommendedSectionText should already read like a report section seed, not a bullet memo. +- Do not return markdown outside the JSON.`; + + case "synthesize": + return `Produce a synthesis in markdown. Include: +- Integrated findings across all sub-questions +- Resolution of conflicting evidence +- Overall conclusions with confidence levels +- Recommendations`; + + default: + return "Produce a clear, structured response addressing the assigned task."; + } +} diff --git a/src/lib/deep-research/prompts.ts b/src/lib/deep-research/prompts.ts index cf250ffb..335dffac 100644 --- a/src/lib/deep-research/prompts.ts +++ b/src/lib/deep-research/prompts.ts @@ -1,757 +1,31 @@ -import type { - DeepResearchSession, - DeepResearchMessage, - DeepResearchNode, - DeepResearchArtifact, - ContextTag, - NodeType, - CheckpointPackage, - ConfirmationOutcome, - ReviewAssessment, - RequirementState, -} from "./types"; -import { - buildRuntimeRoleContract, - listMetaWorkerRoleDefinitions, -} from "./role-registry"; - -// ============================================================= -// RESEARCHER SYSTEM PROMPT -// ============================================================= - -/** - * Build the system prompt for the Researcher orchestrator. - * Includes full context: session state, messages, nodes, artifacts, and the legacy context tag. - */ -export function buildMainBrainSystemPrompt( - session: DeepResearchSession, - messages: DeepResearchMessage[], - nodes: DeepResearchNode[], - artifacts: DeepResearchArtifact[], - contextTag: ContextTag, - requirementState?: RequirementState | null, - workstationContext?: string | null, - memoryContext?: string | null, - doctrineContext?: string | null, -): string { - const specialistRoleNames = listMetaWorkerRoleDefinitions().map((role) => role.roleName).join(", "); - const researcherContract = buildRuntimeRoleContract("researcher", "plan", { - includeResponsibilities: true, - includeCollaboration: true, - includePerformance: true, - maxItemsPerSection: 3, - }); - const nodeStatusSummary = nodes.map((n) => - ` - [${n.id.slice(0, 8)}] ${n.label} (${n.nodeType}, ${n.status}, role=${n.assignedRole}, context=${n.contextTag})` - ).join("\n"); - - const artifactSummary = artifacts - .filter((a) => !a.artifactType.startsWith("memory_")) - .map((a) => { - const contentStr = JSON.stringify(a.content); - const preview = contentStr.length > 500 ? contentStr.slice(0, 500) + "..." : contentStr; - return ` - [${a.id.slice(0, 8)}] ${a.title} (${a.artifactType}): ${preview}`; - }).join("\n"); - - const recentMessages = messages.slice(-10).map((m) => - ` [${m.role}]: ${m.content.slice(0, 300)}${m.content.length > 300 ? "..." : ""}` - ).join("\n"); - - const reviewerPackets = artifacts - .filter((a) => a.artifactType === "reviewer_packet") - .map((a) => ` Reviewer Packet [${a.id.slice(0, 8)}]: ${JSON.stringify(a.content).slice(0, 500)}`) - .join("\n"); - - const reviewAssessments = artifacts - .filter((a) => a.artifactType === "review_assessment") - .map((a) => ` Review Assessment [${a.id.slice(0, 8)}]: ${JSON.stringify(a.content).slice(0, 500)}`) - .join("\n"); - - // Build requirement state section - let requirementSection = ""; - if (requirementState && requirementState.requirements.length > 0) { - const reqLines = requirementState.requirements.map((r) => - ` - [${r.status}] (${r.priority}) ${r.text}${r.satisfiedByNodeIds.length > 0 ? ` [satisfied by: ${r.satisfiedByNodeIds.join(",")}]` : ""}` - ).join("\n"); - const constraintLines = requirementState.constraints.map((c) => - ` - [${c.status}] (${c.type}) ${c.text}: ${c.value}` - ).join("\n"); - requirementSection = ` -## Requirements (v${requirementState.version}) -IMPORTANT: Check each requirement's status before planning. Only create work for ACTIVE requirements. -${reqLines} - -## Constraints -${constraintLines} -`; - } - - const workstationSection = workstationContext - ? `\n## Additional Coordination Context\n${workstationContext}\n` - : ""; - - const memorySection = memoryContext - ? `\n${memoryContext}\n` - : ""; - const doctrineSection = doctrineContext - ? `\n${doctrineContext}\n` - : ""; - - return `You are GPT-5.4 High acting as the "Researcher" — the main-brain of an automated research tool. - -## Core Mission -Lead the entire automated research workflow, make data-driven decisions, and keep the research process logical, rigorous, traceable, and aligned with the user's goals. - -## Structured Role Contract -${researcherContract || " (no structured role contract available)"} - -## Specialist Topology -- Researcher (you) is the top-level coordinator and decision-maker. -- Results and Evidence Analyst reviews are advisory only. They cannot redefine the workflow. -- Specialist roles available for dispatch: ${specialistRoleNames}. - -## Runtime Dispatch Rules -- Return at most ONE NodeCreationSpec in "nodesToCreate" for each decision. -- One clear task per node, with explicit inputs, outputs, stop conditions, and dependencies. -- Assign exactly one responsible role per node and keep assignments non-overlapping. -- Runtime dispatch is node-driven, not stage-driven. Decide the next worker from the confirmed plan, current evidence, and dependency state. -- Do not rely on hidden workflow stages. If a worker should act next, express that directly in "nodesToCreate". - -## Ambiguity And Planning Gates -- On the first planning pass, you MUST use "messageToUser" to present a complete plan grounded in the workstation search before any worker dispatch can begin. -- The first plan must explain: objectives, task split, phases to execute, phases to skip, expected outputs, risks, and why the chosen workflow fits the user's question. -- Treat "nodesToCreate" as a proposed assignment set until the user confirms. -- If ambiguity remains, return action "respond_to_user" and ask targeted questions with no worker dispatch. -- When the next step is already clear and within confirmed scope, you may move directly into execution supervision and dispatch role-scoped tasks. -- Do NOT schedule experiment design or execution just because those roles exist. Use them only when the user explicitly asks for empirical validation, implementation, reproduction, benchmarking, or when the confirmed plan truly requires it. -- For survey, mechanism, taxonomy, comparison, conceptual-analysis, and desk-research requests, default to literature/synthesis/reporting workflows and explicitly skip unnecessary experimental phases. -- Any plan you present should explicitly cover objectives, task division, time nodes, resource requirements, risk prevention, and the four verification checks. - -## Current State -- Session: "${session.title}" (id: ${session.id}) -- Status: ${session.status} -- Legacy Context Tag: ${contextTag} (compatibility only; do not treat this as a required next step) -- Literature round: ${session.literatureRound} / ${session.config.literature.maxLiteratureRounds} -- Reviewer round: ${session.reviewerRound} / ${session.config.maxReviewerRounds} -- Execution loop: ${session.executionLoop} / ${session.config.maxExecutionLoops} -- Budget: ${session.budget.totalTokens} / ${session.config.budget.maxTotalTokens} total tokens -- Opus tokens: ${session.budget.opusTokens} / ${session.config.budget.maxOpusTokens} - -## Task Graph Nodes -${nodeStatusSummary || " (none yet)"} - -## Artifacts -${artifactSummary || " (none yet)"} - -## Analytical Critique Feedback -${reviewerPackets || " (none yet)"} - -## Review Assessments -${reviewAssessments || " (none yet)"} - -## Recent Conversation -${recentMessages || " (no messages yet)"} -${requirementSection} -${workstationSection} -${doctrineSection} -${memorySection} -## Output Format -You MUST respond with valid JSON matching the BrainDecision schema: -{ - "action": "advance_context" | "revise_plan" | "request_approval" | "complete" | "respond_to_user", - "nextContextTag": "(optional legacy context tag for compatibility only)", - "nodesToCreate": [(optional) array of NodeCreationSpec], - "messageToUser": "(optional) message to display", - "reasoning": "(optional) internal reasoning" -} - -NodeCreationSpec: -{ - "nodeType": "evidence_gather|evidence_extract|summarize|synthesize|review|audit|validation_plan|resource_request|execute|monitor|result_collect|result_compare|approve|final_report", - "label": "specific task description", - "assignedRole": "researcher|literature_intelligence_analyst|experiment_architecture_designer|research_software_engineer|experiment_operations_engineer|results_and_evidence_analyst|research_asset_reuse_specialist", - "input": { ... task-specific input ... }, - "dependsOn": ["nodeId1"], - "parentId": "optional parent", - "contextTag": "optional legacy context tag; omit unless needed for compatibility" -} - -## Recommended "messageToUser" Structure For Planning Or Clarification -When you choose to present a plan or clarification request, prefer this structure: -1. Context Review Summary -2. Workstation Search Findings -3. Clarification Questions (only if ambiguity exists) -4. Plan Options -5. Recommended Plan -6. Verification Statement -7. User Confirmation Request - -Inside Recommended Plan, include: -- 1. Core Research Objectives -- 2. Task Division & Responsible Roles -- 3. Time Nodes -- 4. Resource Requirements -- 5. Risk Prevention - -Inside Plan Options, provide 2-3 concrete options when meaningful, each with: -- Scope and intended outcome -- Trade-offs -- Required roles/resources -- Why the user might choose it - -The verification statement must explicitly cover alignment, feasibility, rigor, and completeness. - -## Cost Awareness -- Use specialist roles for bulk work and keep your own reasoning focused on strategic decisions. -- Max specialist fan-out: 1 -- Specialist execution is serial. Only one specialist task runs at a time. -- Literature bounds: max ${session.config.literature.maxPapersPerRound} papers/round, max ${session.config.literature.maxLiteratureRounds} rounds`; -} - -// ============================================================= -// CHECKPOINT + RESEARCHER AUDIT PROMPT -// ============================================================= - -/** - * Build a prompt that asks the Researcher to produce a CheckpointPackage - * WITH a MainBrainAudit section — the Researcher's opinion on the stage result. - */ -export function buildCheckpointPrompt( - session: DeepResearchSession, - completedNode: DeepResearchNode, - artifacts: DeepResearchArtifact[], - nodes: DeepResearchNode[], - contextTag: ContextTag -): string { - const isFinalReportingStep = completedNode.nodeType === "final_report" || contextTag === "final_report"; - const relevantNodeIds = isEvidenceAggregationPhase(contextTag, completedNode, nodes) - ? new Set( - nodes - .filter((node) => - node.nodeType === "evidence_gather" && - node.contextTag === contextTag && - ["completed", "failed", "skipped"].includes(node.status) - ) - .map((node) => node.id) - ) - : null; - - const nodeArtifacts = relevantNodeIds - ? artifacts.filter((artifact) => - artifact.artifactType === "evidence_card" && - Boolean(artifact.nodeId) && - relevantNodeIds.has(artifact.nodeId as string) - ) - : artifacts.filter((artifact) => artifact.nodeId === completedNode.id); - - const evidenceTotalSources = nodeArtifacts.reduce((sum, artifact) => { - const sources = Array.isArray(artifact.content.sources) ? artifact.content.sources : []; - const totalFound = typeof artifact.content.totalFound === "number" - ? artifact.content.totalFound - : typeof artifact.content.papersFound === "number" - ? artifact.content.papersFound - : sources.length; - return sum + Math.max(totalFound, sources.length); - }, 0); - - const artifactPreviews = nodeArtifacts.map((a) => { - const contentStr = JSON.stringify(a.content); - return ` - [${a.id.slice(0, 8)}] ${a.title} (${a.artifactType}): ${contentStr.length > 400 ? contentStr.slice(0, 400) + "..." : contentStr}`; - }).join("\n"); - - const allNodesSummary = nodes.map((n) => - ` - [${n.id.slice(0, 8)}] ${n.label} (${n.nodeType}, ${n.status}, context=${n.contextTag})` - ).join("\n"); - - // Include analytical deliberation results if this context includes critique - const reviewAssessments = isFinalReportingStep - ? artifacts.filter((artifact) => artifact.artifactType === "review_assessment").slice(-1) - : artifacts.filter((artifact) => artifact.artifactType === "review_assessment"); - const reviewSection = reviewAssessments.length > 0 - ? `\n## Review Assessments\n${reviewAssessments.map(review => JSON.stringify(review.content, null, 2)).join("\n")}` - : ""; - const finalReportingRule = isFinalReportingStep - ? ` - -## Final Report Phase Rule -- You are already in the report-writing/final-report phase. -- Do NOT recommend restarting broad literature discovery, restarting literature round counting, or returning to an earlier "round 1/N" style search loop. -- The default next action here is to let the user review/accept the final report or request targeted revisions. -- Only recommend additional literature work if there is a clearly blocking evidence gap that prevents the report from standing as a final deliverable, and frame it as a targeted revision request rather than a restart of the workflow. -` - : ""; - - return `You have just completed a step in a step-gated deep research workflow. -The system will HALT and present your summary to the user for review. - -## Completed Step -- Node: "${completedNode.label}" (${completedNode.nodeType}) -- Role: ${completedNode.assignedRole} -- Status: ${completedNode.status} -- Context Tag: ${contextTag} -${isEvidenceAggregationPhase(contextTag, completedNode, nodes) ? `- Aggregated evidence cards in this literature execution context: ${nodeArtifacts.length}\n- Aggregated sources/papers found in this literature execution context: ${evidenceTotalSources}` : ""} - -## Artifacts Produced -${artifactPreviews || " (none)"} - -## Current Task Graph -${allNodesSummary || " (none)"} -${reviewSection} -${finalReportingRule} - -## Session -- Title: "${session.title}" -- Literature round: ${session.literatureRound} -- Reviewer round: ${session.reviewerRound} -- Execution loop: ${session.executionLoop} -- Tokens used: ${session.budget.totalTokens} / ${session.config.budget.maxTotalTokens} - -## Instructions -Produce a checkpoint summary with your AUDIT/OPINION as JSON: -{ - "title": "Short title for this checkpoint", - "humanSummary": "Clear 2-5 sentence summary for the user. Be specific.", - "machineSummary": "Compact internal summary for your own context.", - "mainBrainAudit": { - "whatWasCompleted": "Description of what this stage accomplished", - "resultAssessment": "good|acceptable|concerning|problematic", - "issuesAndRisks": ["Issue 1", "Issue 2"], - "recommendedNextAction": "What you recommend doing next", - "continueWillDo": "Exactly what clicking Continue will do", - "alternativeActions": [ - {"label": "Continue", "description": "Proceed with recommendation", "actionType": "continue"}, - {"label": "Revise", "description": "Change approach", "actionType": "revise"}, - {"label": "More Literature", "description": "Search for more papers", "actionType": "more_literature"}, - {"label": "Stop", "description": "End research", "actionType": "stop"} - ], - "canProceed": true - }, - "currentFindings": "What we know so far", - "openQuestions": ["Question 1"], - "recommendedNextAction": "What should happen next", - "continueWillDo": "Exactly what clicking Continue will trigger", - "alternativeNextActions": ["Alternative 1"], - "requiresUserConfirmation": true -} - -IMPORTANT: The "continueWillDo" field must clearly state what "Continue" means at this point. -Example: "Continue will let the Researcher route the next workflow, beginning with 3 literature-analysis tasks searching for papers." -NOT vague: "Continue will continue the research."`; -} - -function isEvidenceAggregationPhase( - contextTag: ContextTag, - completedNode: DeepResearchNode, - nodes: DeepResearchNode[], -): boolean { - if (contextTag !== "planning") { - return false; - } - - if (completedNode.nodeType === "evidence_gather") { - return true; - } - - return nodes.some((node) => - node.nodeType === "evidence_gather" && - node.contextTag === "planning" && - ["completed", "failed", "skipped"].includes(node.status) - ); -} - -// ============================================================= -// CONFIRMATION INTERPRETATION PROMPT -// ============================================================= - -export function buildConfirmationInterpretationPrompt( - session: DeepResearchSession, - checkpoint: CheckpointPackage, - outcome: ConfirmationOutcome, - userFeedback: string | undefined, - nodes: DeepResearchNode[], - artifacts: DeepResearchArtifact[], -): string { - const nodesSummary = nodes.map((n) => - ` - [${n.id.slice(0, 8)}] ${n.label} (${n.nodeType}, ${n.status})` - ).join("\n"); - - const latestTaskGraph = artifacts - .filter((artifact) => artifact.artifactType === "task_graph") - .slice(-1)[0]; - const proposedPlanSummary = latestTaskGraph - ? JSON.stringify({ - title: latestTaskGraph.title, - nextTaskCount: latestTaskGraph.content.nextTaskCount ?? latestTaskGraph.content.totalNodes, - skillsUsed: latestTaskGraph.content.skillsUsed, - suggestedNextContextTag: latestTaskGraph.content.suggestedNextContextTag, - nextTask: latestTaskGraph.content.nextTask ?? latestTaskGraph.content.proposedNodeSpecs, - }, null, 2) - : "(no task_graph artifact available)"; - const isFinalReportingCheckpoint = checkpoint.isFinalStep || checkpoint.contextTag === "final_report"; - const finalReportRule = isFinalReportingCheckpoint - ? ` - -## Final-Report Confirmation Rule -This checkpoint is already in the final report phase. -- If the user confirms, treat that as accepting the final report path rather than reopening broad literature discovery. -- Do NOT dispatch new evidence-gather work unless the user explicitly asks to reopen literature review or requests targeted evidence additions. -- If the user wants changes, prefer targeted revisions to the report or its supporting claims instead of restarting from an earlier literature round.` - : ""; - - return `The user has responded to a checkpoint in the step-gated deep research workflow. - -## Checkpoint That Was Presented -- Title: "${checkpoint.title}" -- Context Tag: ${checkpoint.contextTag} -- Summary: ${checkpoint.humanSummary} -- Your recommended next: ${checkpoint.recommendedNextAction} -- "Continue" was described as: ${checkpoint.continueWillDo || checkpoint.recommendedNextAction} - -## User's Response -- Outcome: ${outcome} -${userFeedback ? `- Feedback: "${userFeedback}"` : "- (no additional feedback)"} - -## Current Task Graph -${nodesSummary} - -## Latest Next-Task Plan -${proposedPlanSummary} -${finalReportRule} - -## CRITICAL SEMANTIC RULE -"Continue" means: proceed according to YOUR recommended next action. -It does NOT mean "blindly run the old pipeline." It means the user accepts YOUR recommendation. - -## Next-Task Confirmation Rule -If the checkpoint included a task_graph artifact and the user confirmed it: -- treat the approved next-task artifact as authorized for dispatch; -- return only the NEXT approved worker task in "nodesToCreate"; -- set "nextContextTag" to "planning" unless the approved work is a final report. - -## Literature-Dispatch Rule -If the user confirmed a checkpoint that recommends literature work: -- return at most one explicit evidence_gather task in "nodesToCreate" when new literature work is required; -- assign those tasks to "literature_intelligence_analyst"; -- do not rely on any hidden runtime handler to fabricate fallback searches. - -## Re-Planning Rule -If the checkpoint proposed a next task and the user feedback changes core objectives, task division, time nodes, or resources: -- do NOT dispatch workers; -- return "action": "revise" or "branch" and keep the workflow in planning. - -## Instructions -Respond with JSON: -{ - "action": "continue" | "revise" | "retry" | "branch" | "supersede" | "stop", - "reasoning": "Brief explanation", - "nodesToCreate": [/* optional */], - "nextContextTag": "optional context tag", - "messageToUser": "optional message" -}`; -} - -// ============================================================= -// WORKER SYSTEM PROMPTS -// ============================================================= - -export function buildWorkerSystemPrompt( - node: DeepResearchNode, - parentArtifacts: DeepResearchArtifact[], - taskType: NodeType -): string { - const roleContract = buildRuntimeRoleContract(node.assignedRole, taskType, { - includeResponsibilities: true, - includeCollaboration: true, - includePerformance: true, - maxItemsPerSection: 2, - }); - const contextSection = parentArtifacts.length > 0 - ? "## Context Artifacts\n" + parentArtifacts.map((a) => - `### ${a.title} (${a.artifactType})\n${JSON.stringify(a.content, null, 2)}` - ).join("\n\n") - : ""; - - const outputSchema = getWorkerOutputSchema(taskType); - - return `You are a specialist role executing a specific, scoped subtask. - -## RULES -- Focus ONLY on the assigned task. Do NOT address the broader research question. -- Cite provenance for all claims: which source, which section, what evidence. -- Do NOT hallucinate. If information is missing, say so. -- Do NOT self-assign additional tasks or redefine the plan. -- Do NOT dispatch other roles or make final conclusions. -- Be thorough but concise. Quality over quantity. - -## Structured Role Contract -${roleContract || " (no structured role contract available)"} - -## Your Task -${node.label} - -## Task Input -${node.input ? JSON.stringify(node.input, null, 2) : "(no specific input)"} - -${contextSection} - -## Output Requirements -${outputSchema}`; -} - -function getWorkerOutputSchema(taskType: NodeType): string { - switch (taskType) { - case "evidence_gather": - return `Produce an evidence card as JSON: -{ - "claims": [{"claim": "...", "evidence": "...", "source": "...", "confidence": "high|medium|low"}], - "methods": ["methods identified"], - "datasets": ["datasets mentioned"], - "gaps": ["areas where evidence is insufficient"], - "papersFound": 0, - "searchQueries": ["queries used"], - "confidence": 0.0-1.0 -} -Stay within the specified paper limit. Do not do unbounded searching.`; - - case "evidence_extract": - return `Extract structured information from the provided papers/sources: -{ - "extractions": [ - {"source": "...", "objective": "...", "method": "...", "results": "...", "limitations": "..."} - ], - "crossReferences": ["connections between sources"], - "confidence": 0.0-1.0 -}`; - - case "execute": - return `Produce a step result as JSON: -{ - "status": "success|failure|partial", - "outputs": { ... }, - "commands": ["commands executed"], - "observations": ["key observations"], - "errors": ["any errors"], - "metrics": { ... } -}`; - - case "resource_request": - return `Produce a resource request manifest as JSON: -{ - "launcherType": "rlaunch|rjob", - "resources": {"gpu": N, "memoryMb": N, "cpu": N}, - "purpose": "what this resource is for", - "estimatedDuration": "estimate", - "manifest": { ... full manifest fields ... } -}`; - - case "monitor": - return `Produce a monitoring report as JSON: -{ - "jobStatus": "running|completed|failed|unknown", - "progress": "description of progress", - "metrics": { ... }, - "issues": ["any issues observed"], - "estimatedCompletion": "estimate" -}`; - - case "result_collect": - return `Collect and package results as JSON: -{ - "outputs": { ... collected files/metrics ... }, - "summary": "brief summary of results", - "completeness": "complete|partial|failed", - "missingOutputs": ["expected outputs not found"] -}`; - - case "result_compare": - return `Compare results against expectations as JSON: -{ - "hypothesis": "what was expected", - "actualResult": "what happened", - "match": "confirmed|partially_confirmed|contradicted|inconclusive", - "metrics": { ... }, - "analysis": "detailed comparison", - "confidence": 0.0-1.0 -}`; - - case "summarize": - return `Produce a structured summary in markdown. Include: -- Key findings organized by sub-question -- Evidence strength assessment -- Gaps and limitations -- Cross-references between findings`; - - case "synthesize": - return `Produce a synthesis in markdown. Include: -- Integrated findings across all sub-questions -- Resolution of conflicting evidence -- Overall conclusions with confidence levels -- Recommendations`; - - default: - return `Produce a clear, structured response addressing the assigned task.`; - } -} - -// ============================================================= -// ANALYTICAL REVIEW PROMPT -// ============================================================= - -export function buildReviewerSystemPrompt( - role: "results_and_evidence_analyst", - targetArtifacts: DeepResearchArtifact[], - previousPackets?: DeepResearchArtifact[], - roundInfo?: { round: number; maxRounds: number } -): string { - const artifactsSection = targetArtifacts.map((a) => - `### ${a.title} (${a.artifactType})\n${JSON.stringify(a.content, null, 2)}` - ).join("\n\n"); - - const previousSection = previousPackets && previousPackets.length > 0 - ? "\n## Previous Review Rounds\n" + previousPackets.map((p) => - `### ${p.title}\n${JSON.stringify(p.content, null, 2)}` - ).join("\n\n") - : ""; - - const roleLabel = "Results and Evidence Analyst"; - const roundLabel = roundInfo ? ` (Round ${roundInfo.round} of ${roundInfo.maxRounds})` : ""; - - return `You are ${roleLabel} in a Deep Research review process${roundLabel}. - -## YOUR ROLE AND LIMITS -- You CRITIQUE the Researcher's synthesis. You provide advisory feedback. -- You CANNOT dispatch workers, search for papers, or run experiments. -- You CANNOT modify the workflow graph. -- You CANNOT override the Researcher's decisions. -- You CAN identify specific gaps that need more literature. -- You CAN suggest specific experiments that would validate claims. -- Your recommendations go to the Researcher, who decides whether to act on them. - -## Artifacts to Review -${artifactsSection} -${previousSection} - -## Output Format -Respond with valid JSON: -{ - "reviewerRole": "${role}", - "verdict": "approve|revise|reject", - "critique": "Detailed critique — be specific, cite evidence", - "suggestions": ["Actionable suggestion 1", "Suggestion 2"], - "confidence": 0.0-1.0, - "identifiedGaps": ["Specific literature gaps found — be precise about what's missing"], - "needsExperimentalValidation": true/false, - "suggestedExperiments": ["Specific experiment if validation needed"] -} - -## Guidelines -- Be specific. "The analysis is weak" is useless. "The claim about X lacks supporting evidence from controlled experiments" is useful. -- Identify: logical gaps, unsupported claims, missing baselines, methodology issues, novelty issues. -- For literature gaps: state EXACTLY what is missing (e.g., "Missing comparison against method Y on dataset Z"). -- "revise" = has promise, needs specific improvements. -- "approve" = meets research standard. -- "reject" = fundamental issues.`; -} - -// ============================================================= -// EVIDENCE GATHERING PROMPT -// ============================================================= - -export function buildEvidenceGatherPrompt( - query: string, - constraints?: { maxSources?: number; focusAreas?: string[] } -): string { - const maxSources = constraints?.maxSources ?? 10; - return `Search for and gather evidence related to: - -## Query -${query} - -## Constraints -- Maximum sources to find: ${maxSources} -- Focus areas: ${constraints?.focusAreas?.join(", ") || "none specified — use your judgment"} - -## Instructions - -**You MUST use the searchArticles tool to find papers.** Do not skip tool usage. - -1. First, call searchArticles with broad keywords extracted from the query (2-4 keywords). -2. If the first search returns few results, try again with different/broader keywords. -3. Try variations: synonyms, related terms, shorter keyword lists. -4. Search both arXiv and Hugging Face sources. - -For each source found, extract: -1. Source (paper title, URL) -2. Relevant findings or excerpts -3. Methodology used -4. Confidence in evidence quality (high/medium/low) -5. How it relates to the query - -After searching, respond with a JSON object: -\`\`\`json -{ - "sources": [{ "title": "...", "url": "...", "findings": "...", "methodology": "...", "confidence": "high|medium|low", "relevance": "..." }], - "totalFound": <number>, - "searchQueries": ["keywords used..."], - "coverageSummary": "Brief description of what was found" -} -\`\`\` - -Be systematic. Cover the query from multiple angles if possible. -STOP when you have found ${maxSources} relevant sources or exhausted available search results. -Do NOT do unbounded searching. Quality over quantity.`; -} - -// ============================================================= -// VALIDATION PLAN PROMPT -// ============================================================= - -export function buildValidationPlanPrompt( - session: DeepResearchSession, - synthesisArtifacts: DeepResearchArtifact[], - reviewAssessment: ReviewAssessment | null -): string { - const synthesisSection = synthesisArtifacts.map(a => - `### ${a.title}\n${JSON.stringify(a.content, null, 2)}` - ).join("\n\n"); - - const reviewSection = reviewAssessment - ? `## Reviewer Outcome\n${JSON.stringify(reviewAssessment, null, 2)}` - : ""; - - return `Convert the research findings into a concrete validation plan. - -## Research Findings -${synthesisSection} - -${reviewSection} - -## User's Original Question -${session.title} - -## Instructions -Produce a validation plan as JSON: -{ - "objective": "What we are trying to validate", - "hypothesis": "The specific hypothesis to test", - "literaturePrediction": "What literature suggests should happen", - "requiredResources": {"gpu": N, "memoryMb": N, "cpu": N, "privateMachine": "yes|no|group"}, - "datasets": ["dataset1", "dataset2"], - "steps": [ - { - "stepNumber": 1, - "description": "What this step does", - "command": "command to run (if applicable)", - "scriptPath": "path/to/script (if applicable)", - "launcherType": "rjob|rlaunch|local_shell", - "requiresApproval": true, - "expectedDuration": "estimate" - } - ], - "expectedOutputs": ["metric1", "metric2"], - "failureCriteria": ["condition that means the hypothesis is false"], - "successCriteria": ["condition that confirms the hypothesis"] -} - -Be specific and executable. Each step should be doable by the appropriate specialist role.`; -} +export { buildMainBrainSystemPrompt } from "./prompt-builders/main-brain-prompt"; +export { + buildCheckpointPrompt, + buildConfirmationInterpretationPrompt, +} from "./prompt-builders/checkpoint-prompt"; +export { + buildWorkerSystemPrompt, + buildEvidenceGatherPrompt, + buildValidationPlanPrompt, +} from "./prompt-builders/worker-prompts"; +export { buildReviewerSystemPrompt } from "./prompt-builders/review-prompt"; +export { + analyzeFinalReportCitationCoverage, + appendDeterministicReferencesSection, + assembleFinalReportFromSections, + buildFinalReportCitationEntries, + buildFinalReportCoverageRevisionPrompt, + buildFinalReportPromptBundle, + buildFinalReportPlannerSystemPrompt, + buildFinalReportSectionCitationRevisionPrompt, + buildFinalReportSectionDraftPrompt, + buildFinalReportSectionPlanPrompt, + buildFinalReportSystemPrompt, + buildFinalReportPrompt, + extractRecognizedCitationKeys, + getFinalReportDraftingOrder, + getRelevantChapterPacketsForSection, + getMinimumRequiredCitationCount, + isSurveyLikeResearchRequest, + normalizeFinalReportSectionPlan, +} from "./prompt-builders/final-report-prompt"; diff --git a/src/lib/deep-research/record-types.ts b/src/lib/deep-research/record-types.ts new file mode 100644 index 00000000..18d2afdb --- /dev/null +++ b/src/lib/deep-research/record-types.ts @@ -0,0 +1,107 @@ +import type { + DeepResearchConfig, + BudgetUsage, +} from "./config-types"; +import type { + ArtifactType, + ContextTag, + EventType, + MessageRole, + ModelRole, + NodeStatus, + NodeType, + SessionStatus, + ConfirmationOutcome, +} from "./status-types"; + +export interface ArtifactProvenance { + sourceNodeId: string; + sourceArtifactIds: string[]; + model: string; + generatedAt: string; +} + +export interface DeepResearchSession { + id: string; + workspaceId: string; + title: string; + status: SessionStatus; + contextTag: ContextTag; + config: DeepResearchConfig; + budget: BudgetUsage; + pendingCheckpointId: string | null; + literatureRound: number; + reviewerRound: number; + executionLoop: number; + error: string | null; + remoteProfileId: string | null; + createdAt: string; + updatedAt: string; +} + +export interface DeepResearchMessage { + id: string; + sessionId: string; + role: MessageRole; + content: string; + metadata: Record<string, unknown> | null; + relatedNodeId: string | null; + relatedArtifactIds: string[]; + createdAt: string; +} + +export interface DeepResearchNode { + id: string; + sessionId: string; + parentId: string | null; + nodeType: NodeType; + label: string; + status: NodeStatus; + assignedRole: ModelRole; + assignedModel: string | null; + input: Record<string, unknown> | null; + output: Record<string, unknown> | null; + error: string | null; + dependsOn: string[]; + supersedesId: string | null; + supersededById: string | null; + branchKey: string | null; + retryOfId: string | null; + retryCount: number; + contextTag: ContextTag; + stageNumber: number; + requiresConfirmation: boolean; + confirmedAt: string | null; + confirmedBy: string | null; + confirmationOutcome: ConfirmationOutcome | null; + positionX: number | null; + positionY: number | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface DeepResearchArtifact { + id: string; + sessionId: string; + nodeId: string | null; + artifactType: ArtifactType; + title: string; + content: Record<string, unknown>; + provenance: ArtifactProvenance | null; + version: number; + createdAt: string; +} + +export interface DeepResearchEvent { + id: string; + sessionId: string; + eventType: EventType; + nodeId: string | null; + actorType: string | null; + actorId: string | null; + model: string | null; + payload: Record<string, unknown> | null; + createdAt: string; +} diff --git a/src/lib/deep-research/refresh-policy.test.ts b/src/lib/deep-research/refresh-policy.test.ts new file mode 100644 index 00000000..91d0621a --- /dev/null +++ b/src/lib/deep-research/refresh-policy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + ACTIVE_DEEP_RESEARCH_REFRESH_MS, + IDLE_DEEP_RESEARCH_REFRESH_MS, + TERMINAL_DEEP_RESEARCH_REFRESH_MS, + getExecutionRefreshInterval, + getFullSessionRefreshInterval, + getSessionRefreshInterval, +} from "./refresh-policy"; + +describe("refresh-policy", () => { + it("uses active polling for non-terminal sessions", () => { + expect(getSessionRefreshInterval({ status: "running" })).toBe(ACTIVE_DEEP_RESEARCH_REFRESH_MS); + expect(getSessionRefreshInterval({ status: "awaiting_user_confirmation" })).toBe(ACTIVE_DEEP_RESEARCH_REFRESH_MS); + }); + + it("uses idle polling for completed-like session views", () => { + expect(getSessionRefreshInterval({ status: "completed" })).toBe(IDLE_DEEP_RESEARCH_REFRESH_MS); + expect(getSessionRefreshInterval({ status: "failed" })).toBe(IDLE_DEEP_RESEARCH_REFRESH_MS); + }); + + it("uses terminal polling cadence for full session data", () => { + expect(getFullSessionRefreshInterval({ status: "completed" })).toBe(TERMINAL_DEEP_RESEARCH_REFRESH_MS); + expect(getFullSessionRefreshInterval({ status: "awaiting_user_confirmation" })).toBe(ACTIVE_DEEP_RESEARCH_REFRESH_MS); + }); + + it("slows execution polling when all runs are terminal", () => { + expect(getExecutionRefreshInterval([ + { status: "running" } as never, + ])).toBe(5_000); + expect(getExecutionRefreshInterval([ + { status: "completed" } as never, + ])).toBe(IDLE_DEEP_RESEARCH_REFRESH_MS); + }); +}); diff --git a/src/lib/deep-research/refresh-policy.ts b/src/lib/deep-research/refresh-policy.ts new file mode 100644 index 00000000..bc1ad1de --- /dev/null +++ b/src/lib/deep-research/refresh-policy.ts @@ -0,0 +1,56 @@ +import type { PersistedExecutionRecord, SessionStatus } from "./types"; +import { isCompletedSessionStatus } from "./session-status"; + +const TERMINAL_SESSION_STATUSES = new Set<SessionStatus>([ + "completed", + "stopped_by_user", + "failed", + "cancelled", + "final_report_generated", +]); + +const AWAITING_SESSION_STATUSES = new Set<SessionStatus>([ + "awaiting_user_confirmation", + "execution_prepared", + "awaiting_additional_literature", +]); + +export const ACTIVE_DEEP_RESEARCH_REFRESH_MS = 2_000; +export const IDLE_DEEP_RESEARCH_REFRESH_MS = 30_000; +export const TERMINAL_DEEP_RESEARCH_REFRESH_MS = 60_000; + +export function getSessionRefreshInterval(session: { status: SessionStatus } | null | undefined): number { + if (!session) return ACTIVE_DEEP_RESEARCH_REFRESH_MS; + if ( + isCompletedSessionStatus(session.status) || + session.status === "stopped_by_user" || + session.status === "failed" || + session.status === "cancelled" + ) { + return IDLE_DEEP_RESEARCH_REFRESH_MS; + } + + return ACTIVE_DEEP_RESEARCH_REFRESH_MS; +} + +export function getArtifactRefreshInterval(): number { + return ACTIVE_DEEP_RESEARCH_REFRESH_MS; +} + +export function getFullSessionRefreshInterval(session: { status: SessionStatus } | null | undefined): number { + if (!session) return ACTIVE_DEEP_RESEARCH_REFRESH_MS; + if (TERMINAL_SESSION_STATUSES.has(session.status)) return TERMINAL_DEEP_RESEARCH_REFRESH_MS; + if (AWAITING_SESSION_STATUSES.has(session.status)) return ACTIVE_DEEP_RESEARCH_REFRESH_MS; + return ACTIVE_DEEP_RESEARCH_REFRESH_MS; +} + +export function getExecutionRefreshInterval( + records: PersistedExecutionRecord[] | null | undefined, +): number { + if (!records || records.length === 0) { + return 5_000; + } + + const hasActive = records.some((record) => ["pending", "submitted", "running"].includes(record.status)); + return hasActive ? 5_000 : IDLE_DEEP_RESEARCH_REFRESH_MS; +} diff --git a/src/lib/deep-research/remote-executor.ts b/src/lib/deep-research/remote-executor.ts deleted file mode 100644 index 62ccb297..00000000 --- a/src/lib/deep-research/remote-executor.ts +++ /dev/null @@ -1,564 +0,0 @@ -// ============================================================= -// Remote Executor — SSH-backed execution for cluster workflows -// ============================================================= -// Provides SSH remote command execution, file staging, log fetching, -// and integration with rjob/rlaunch on remote machines. - -import type { - RemoteExecutionConfig, - ExperimentSpec, - JobSubmissionResult, - JobStatusResult, - JobLogResult, - JobOutputResult, - JobStatus, - SubmissionMode, - LauncherType, -} from "./types"; -import { DEFAULT_REMOTE_EXECUTION_CONFIG } from "./types"; -import { specToRJobManifest, specToRLaunchManifest } from "./exec-job-submitter"; -import { rjobToCommand, rlaunchToCommand } from "./execution-adapters"; - -// ------------------------------------------------------------------- -// SSH Command Runner (injectable for testing) -// ------------------------------------------------------------------- - -export interface SSHCommandResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export type SSHCommandRunner = ( - config: RemoteExecutionConfig, - command: string, - timeoutMs?: number, -) => Promise<SSHCommandResult>; - -/** Default SSH runner using child_process. */ -async function defaultSSHRunner( - config: RemoteExecutionConfig, - command: string, - timeoutMs?: number, -): Promise<SSHCommandResult> { - const { execSync } = await import("child_process"); - const keyArg = config.keyPath && config.keyPath !== "agent" - ? `-i ${config.keyPath}` - : ""; - const sshCmd = [ - "ssh", - `-p ${config.port}`, - keyArg, - "-o StrictHostKeyChecking=no", - "-o ConnectTimeout=" + Math.ceil(config.connectTimeoutMs / 1000), - `${config.username}@${config.host}`, - `'${command.replace(/'/g, "'\\''")}'`, - ].filter(Boolean).join(" "); - - try { - const stdout = execSync(sshCmd, { - encoding: "utf-8", - timeout: timeoutMs ?? config.commandTimeoutMs, - }); - return { stdout, stderr: "", exitCode: 0 }; - } catch (error: unknown) { - const err = error as { stdout?: string; stderr?: string; status?: number }; - return { - stdout: err.stdout ?? "", - stderr: err.stderr ?? "", - exitCode: err.status ?? 1, - }; - } -} - -let _sshRunner: SSHCommandRunner = defaultSSHRunner; - -export function setSSHRunner(runner: SSHCommandRunner): void { - _sshRunner = runner; -} - -export function resetSSHRunner(): void { - _sshRunner = defaultSSHRunner; -} - -// ------------------------------------------------------------------- -// SCP File Transfer (injectable) -// ------------------------------------------------------------------- - -export type SCPTransfer = ( - config: RemoteExecutionConfig, - localPath: string, - remotePath: string, - direction: "upload" | "download", -) => Promise<{ success: boolean; error?: string }>; - -async function defaultSCPTransfer( - config: RemoteExecutionConfig, - localPath: string, - remotePath: string, - direction: "upload" | "download", -): Promise<{ success: boolean; error?: string }> { - const { execSync } = await import("child_process"); - const keyArg = config.keyPath && config.keyPath !== "agent" - ? `-i ${config.keyPath}` - : ""; - const remote = `${config.username}@${config.host}:${remotePath}`; - const cmd = direction === "upload" - ? `scp -P ${config.port} ${keyArg} -o StrictHostKeyChecking=no -r ${localPath} ${remote}` - : `scp -P ${config.port} ${keyArg} -o StrictHostKeyChecking=no -r ${remote} ${localPath}`; - - try { - execSync(cmd, { encoding: "utf-8", timeout: config.commandTimeoutMs }); - return { success: true }; - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : "SCP failed" }; - } -} - -let _scpTransfer: SCPTransfer = defaultSCPTransfer; - -export function setSCPTransfer(transfer: SCPTransfer): void { - _scpTransfer = transfer; -} - -export function resetSCPTransfer(): void { - _scpTransfer = defaultSCPTransfer; -} - -// ------------------------------------------------------------------- -// Remote Executor Class -// ------------------------------------------------------------------- - -export class RemoteExecutor { - private config: RemoteExecutionConfig; - - constructor(config: Partial<RemoteExecutionConfig>) { - this.config = { ...DEFAULT_REMOTE_EXECUTION_CONFIG, ...config }; - } - - /** Test SSH connectivity. */ - async testConnection(): Promise<{ connected: boolean; message: string }> { - if (!this.config.host || !this.config.username) { - return { connected: false, message: "Missing host or username in remote config" }; - } - try { - const result = await _sshRunner(this.config, "echo __ALIVE__", this.config.connectTimeoutMs); - if (result.stdout.includes("__ALIVE__")) { - return { connected: true, message: `Connected to ${this.config.host}` }; - } - return { connected: false, message: `Unexpected response: ${result.stdout.slice(0, 100)}` }; - } catch (error) { - return { connected: false, message: error instanceof Error ? error.message : "Connection failed" }; - } - } - - /** Check which launchers are available on the remote. */ - async detectLaunchers(): Promise<LauncherType[]> { - const found: LauncherType[] = []; - for (const launcher of ["rjob", "rlaunch", "sbatch"] as const) { - const result = await _sshRunner(this.config, `which ${launcher} 2>/dev/null`, 10_000); - if (result.exitCode === 0 && result.stdout.trim()) { - if (launcher === "sbatch") found.push("slurm"); - else found.push(launcher); - } - } - return found; - } - - /** Stage files to remote working directory. */ - async stageFiles( - localPaths: string[], - remoteSubdir?: string, - ): Promise<{ success: boolean; remotePaths: string[]; errors: string[] }> { - const remoteBase = remoteSubdir - ? `${this.config.remoteWorkDir}/${remoteSubdir}` - : this.config.remoteWorkDir; - - // Create remote directory - await _sshRunner(this.config, `mkdir -p ${remoteBase}`); - - const remotePaths: string[] = []; - const errors: string[] = []; - - for (const localPath of localPaths) { - const filename = localPath.split("/").pop() ?? localPath; - const remotePath = `${remoteBase}/${filename}`; - const result = await _scpTransfer(this.config, localPath, remotePath, "upload"); - if (result.success) { - remotePaths.push(remotePath); - } else { - errors.push(`Failed to stage ${localPath}: ${result.error}`); - } - } - - return { success: errors.length === 0, remotePaths, errors }; - } - - /** Run setup commands on remote. */ - async setupEnvironment(): Promise<SSHCommandResult> { - if (this.config.remoteSetupCommands.length === 0) { - return { stdout: "", stderr: "", exitCode: 0 }; - } - const combined = this.config.remoteSetupCommands.join(" && "); - return _sshRunner(this.config, combined); - } - - /** Submit a job through the remote machine. */ - async submitJob( - spec: ExperimentSpec, - mode: SubmissionMode, - ): Promise<JobSubmissionResult> { - const launcher = spec.launcherType; - - if (mode === "dry_run") { - const rendered = this.renderCommand(spec); - return { - success: true, - jobId: null, - message: `Dry-run via SSH to ${this.config.host}: ${rendered.slice(0, 200)}`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: rendered, - metadata: { adapter: "ssh", host: this.config.host, launcher }, - }; - } - - const command = this.renderCommand(spec); - const setupAndRun = this.config.remoteSetupCommands.length > 0 - ? `${this.config.remoteSetupCommands.join(" && ")} && ${command}` - : command; - - try { - const result = await _sshRunner(this.config, setupAndRun); - - if (result.exitCode !== 0) { - return { - success: false, - jobId: null, - message: `SSH submission failed (exit ${result.exitCode}): ${result.stderr.slice(0, 300)}`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: command, - metadata: { adapter: "ssh", host: this.config.host, stderr: result.stderr.slice(0, 500) }, - }; - } - - const jobId = parseJobIdFromOutput(result.stdout, launcher); - - return { - success: true, - jobId, - message: `Submitted via SSH (${this.config.host}) → ${launcher}: ${jobId}`, - submittedAt: new Date().toISOString(), - mode, - renderedSpec: command, - metadata: { adapter: "ssh", host: this.config.host, rawOutput: result.stdout.slice(0, 500) }, - }; - } catch (error) { - return { - success: false, - jobId: null, - message: error instanceof Error ? error.message : "SSH submission failed", - submittedAt: new Date().toISOString(), - mode, - renderedSpec: command, - metadata: { adapter: "ssh", host: this.config.host }, - }; - } - } - - /** Query job status on the remote machine. */ - async queryStatus(jobId: string, launcher: LauncherType = "rjob"): Promise<JobStatusResult> { - const cmd = launcher === "rjob" - ? `rjob status ${jobId}` - : launcher === "slurm" - ? `squeue -j ${jobId} -h -o "%T"` - : `rjob status ${jobId}`; - - try { - const result = await _sshRunner(this.config, cmd, 15_000); - const status = parseStatusFromOutput(result.stdout, launcher); - return { - jobId, - status, - message: result.stdout.trim().slice(0, 200), - queriedAt: new Date().toISOString(), - }; - } catch { - return { - jobId, - status: "unknown", - message: "Failed to query status via SSH", - queriedAt: new Date().toISOString(), - }; - } - } - - /** Fetch logs from a remote job. */ - async fetchLogs(jobId: string, launcher: LauncherType = "rjob", maxLines = 500): Promise<JobLogResult> { - const cmd = launcher === "rjob" - ? `rjob logs ${jobId} 2>&1 | tail -${maxLines}` - : launcher === "slurm" - ? `tail -${maxLines} slurm-${jobId}.out 2>/dev/null; echo "---STDERR---"; tail -${maxLines} slurm-${jobId}.err 2>/dev/null` - : `rjob logs ${jobId} 2>&1 | tail -${maxLines}`; - - try { - const result = await _sshRunner(this.config, cmd, 30_000); - const parts = result.stdout.split("---STDERR---"); - return { - jobId, - stdout: (parts[0] ?? "").trim(), - stderr: (parts[1] ?? result.stderr).trim(), - truncated: result.stdout.split("\n").length >= maxLines, - fetchedAt: new Date().toISOString(), - }; - } catch { - return { - jobId, - stdout: "", - stderr: "Failed to fetch logs via SSH", - truncated: false, - fetchedAt: new Date().toISOString(), - }; - } - } - - /** Fetch output files and metrics from a remote job. */ - async fetchOutputs(outputDir: string): Promise<JobOutputResult> { - // List files in the output directory - const listCmd = `find ${outputDir} -type f -printf '%s %p\\n' 2>/dev/null | head -100`; - const listResult = await _sshRunner(this.config, listCmd, 15_000); - - const files: JobOutputResult["files"] = []; - for (const line of listResult.stdout.trim().split("\n")) { - if (!line.trim()) continue; - const spaceIdx = line.indexOf(" "); - if (spaceIdx < 0) continue; - const sizeBytes = parseInt(line.slice(0, spaceIdx), 10); - const path = line.slice(spaceIdx + 1); - const isMetrics = /metrics|results|scores|eval/i.test(path) && path.endsWith(".json"); - files.push({ path, sizeBytes: isNaN(sizeBytes) ? 0 : sizeBytes, isMetrics }); - } - - // Try to parse metrics from JSON files - let metrics: Record<string, number> = {}; - let metricsRaw: string | null = null; - const metricsFiles = files.filter(f => f.isMetrics); - if (metricsFiles.length > 0) { - const catCmd = `cat ${metricsFiles[0].path} 2>/dev/null`; - const catResult = await _sshRunner(this.config, catCmd, 10_000); - if (catResult.exitCode === 0) { - metricsRaw = catResult.stdout; - try { - const parsed = JSON.parse(catResult.stdout); - metrics = extractFlatMetrics(parsed); - } catch { /* ignore parse errors */ } - } - } - - return { - jobId: outputDir, - files, - metrics, - metricsRaw, - fetchedAt: new Date().toISOString(), - }; - } - - /** Cancel a job on the remote machine. */ - async cancelJob(jobId: string, launcher: LauncherType = "rjob"): Promise<{ success: boolean; message: string }> { - const cmd = launcher === "rjob" - ? `rjob cancel ${jobId}` - : launcher === "slurm" - ? `scancel ${jobId}` - : `rjob cancel ${jobId}`; - - try { - const result = await _sshRunner(this.config, cmd, 15_000); - return { - success: result.exitCode === 0, - message: result.exitCode === 0 ? `Cancelled ${jobId}` : result.stderr.slice(0, 200), - }; - } catch (error) { - return { success: false, message: error instanceof Error ? error.message : "Cancel failed" }; - } - } - - /** Download outputs from remote to local. */ - async downloadOutputs( - remotePath: string, - localPath: string, - ): Promise<{ success: boolean; error?: string }> { - return _scpTransfer(this.config, localPath, remotePath, "download"); - } - - /** Render the submission command for a spec. */ - renderCommand(spec: ExperimentSpec): string { - if (spec.launcherType === "rjob") { - const manifest = specToRJobManifest(spec); - return rjobToCommand(manifest); - } - if (spec.launcherType === "rlaunch") { - const manifest = specToRLaunchManifest(spec); - return rlaunchToCommand(manifest); - } - // Fallback: shell command - const cmds = spec.commands.map(c => `${c.command} ${c.args.join(" ")}`); - return cmds.join(" && "); - } - - getConfig(): RemoteExecutionConfig { - return { ...this.config }; - } -} - -// ------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------- - -function parseJobIdFromOutput(output: string, launcher: LauncherType): string { - // Try common patterns - const patterns = [ - /job[_\s-]?id[:\s]+(\S+)/i, - /Submitted batch job (\d+)/, - /(\d{5,})/, - ]; - for (const pat of patterns) { - const match = output.match(pat); - if (match) return match[1]; - } - return `${launcher}-${Date.now()}`; -} - -function parseStatusFromOutput(output: string, _launcher: LauncherType): JobStatus { - const lower = output.toLowerCase().trim(); - if (lower.includes("completed") || lower.includes("finished") || lower === "cd") return "completed"; - if (lower.includes("running") || lower === "r") return "running"; - if (lower.includes("pending") || lower.includes("queued") || lower === "pd") return "queued"; - if (lower.includes("failed") || lower.includes("error") || lower === "f") return "failed"; - if (lower.includes("cancel") || lower === "ca") return "cancelled"; - return "unknown"; -} - -/** Extract flat numeric metrics from a possibly nested JSON object. */ -function extractFlatMetrics(obj: unknown, prefix = ""): Record<string, number> { - const result: Record<string, number> = {}; - if (obj === null || obj === undefined) return result; - if (typeof obj === "number") { - if (prefix) result[prefix] = obj; - return result; - } - if (typeof obj !== "object") return result; - for (const [key, value] of Object.entries(obj as Record<string, unknown>)) { - const fullKey = prefix ? `${prefix}.${key}` : key; - if (typeof value === "number" && isFinite(value)) { - result[fullKey] = value; - } else if (typeof value === "object" && value !== null && !Array.isArray(value)) { - Object.assign(result, extractFlatMetrics(value, fullKey)); - } - } - return result; -} - -// ------------------------------------------------------------------- -// SSH Submission Adapter (implements SubmissionAdapter interface) -// ------------------------------------------------------------------- - -import type { SubmissionAdapter } from "./exec-job-submitter"; - -// ------------------------------------------------------------------- -// Profile → Config loader -// ------------------------------------------------------------------- - -/** - * Load a remote profile by ID from the database and construct a - * RemoteExecutionConfig suitable for creating a RemoteExecutor. - * Returns null if no profile is found. - */ -export async function loadRemoteConfigFromProfile( - profileId: string, -): Promise<RemoteExecutionConfig | null> { - const { db } = await import("@/lib/db"); - const { remoteProfiles } = await import("@/lib/db/schema"); - const { eq } = await import("drizzle-orm"); - - const [row] = await db - .select() - .from(remoteProfiles) - .where(eq(remoteProfiles.id, profileId)); - - if (!row) return null; - - return { - host: row.host, - port: row.port, - username: row.username, - keyPath: row.sshKeyRef ?? "agent", - remoteWorkDir: row.remotePath, - remoteSetupCommands: [], - availableLaunchers: row.schedulerType === "rjob" - ? ["rjob"] - : row.schedulerType === "slurm" - ? ["slurm"] - : ["rjob", "rlaunch"], - connectTimeoutMs: DEFAULT_REMOTE_EXECUTION_CONFIG.connectTimeoutMs, - commandTimeoutMs: DEFAULT_REMOTE_EXECUTION_CONFIG.commandTimeoutMs, - }; -} - -/** - * Build a RemoteExecutor from a deep-research session's bound profile. - * Returns null if the session has no bound profile or the profile doesn't exist. - */ -export async function buildExecutorForSession( - sessionId: string, -): Promise<RemoteExecutor | null> { - const { getSession } = await import("./event-store"); - const session = await getSession(sessionId); - if (!session?.remoteProfileId) return null; - - const config = await loadRemoteConfigFromProfile(session.remoteProfileId); - if (!config) return null; - - return new RemoteExecutor(config); -} - -export class SSHSubmissionAdapter implements SubmissionAdapter { - readonly name = "ssh"; - readonly launcherType: LauncherType = "ssh"; - private executor: RemoteExecutor; - private innerLauncher: LauncherType; - - constructor(config: Partial<RemoteExecutionConfig>, innerLauncher: LauncherType = "rjob") { - this.executor = new RemoteExecutor(config); - this.innerLauncher = innerLauncher; - } - - renderSpec(spec: ExperimentSpec): string { - return this.executor.renderCommand({ ...spec, launcherType: this.innerLauncher }); - } - - async submit(spec: ExperimentSpec, mode: SubmissionMode): Promise<JobSubmissionResult> { - return this.executor.submitJob({ ...spec, launcherType: this.innerLauncher }, mode); - } - - async queryStatus(jobId: string): Promise<JobStatusResult> { - return this.executor.queryStatus(jobId, this.innerLauncher); - } - - async cancel(jobId: string): Promise<{ success: boolean; message: string }> { - return this.executor.cancelJob(jobId, this.innerLauncher); - } - - async fetchLogs(jobId: string): Promise<JobLogResult> { - return this.executor.fetchLogs(jobId, this.innerLauncher); - } - - async fetchOutputs(outputDir: string): Promise<JobOutputResult> { - return this.executor.fetchOutputs(outputDir); - } - - getExecutor(): RemoteExecutor { - return this.executor; - } -} diff --git a/src/lib/deep-research/researcher-runtime.ts b/src/lib/deep-research/researcher-runtime.ts index 13ea4836..b60ee016 100644 --- a/src/lib/deep-research/researcher-runtime.ts +++ b/src/lib/deep-research/researcher-runtime.ts @@ -4,6 +4,7 @@ import * as store from "./event-store"; import { extractJsonFromLLMResponse } from "./json-response"; import { buildMainBrainSystemPrompt } from "./prompts"; import { buildResearchMemoryPromptBlock } from "./memory-fabric"; +import { buildResearchContextArchivePromptBlock } from "./context-archive"; import { buildResearcherDoctrinePromptBlock } from "./researcher-doctrine"; import type { DeepResearchSession, @@ -46,10 +47,21 @@ export async function callMainBrain( requirementState, query: `${session.contextTag} ${session.title} ${latestUserMessage}`.trim(), }); + const archiveContext = await buildResearchContextArchivePromptBlock({ + session, + messages, + artifacts, + query: `${session.contextTag} ${session.title} ${latestUserMessage}`.trim(), + topK: 5, + maxChars: 2200, + }); const doctrineContext = await buildResearcherDoctrinePromptBlock({ contextTag: session.contextTag, query: `${session.contextTag} ${session.title} ${latestUserMessage}`.trim(), }); + const combinedMemoryContext = [memoryContext, archiveContext] + .filter((block): block is string => typeof block === "string" && block.length > 0) + .join("\n\n"); const modelChain = getModelChainForRole("main_brain", session.config); let systemPrompt = buildMainBrainSystemPrompt( @@ -60,7 +72,7 @@ export async function callMainBrain( session.contextTag, requirementState, workstationContext, - memoryContext, + combinedMemoryContext || null, doctrineContext, ); diff --git a/src/lib/deep-research/review-assessment.ts b/src/lib/deep-research/review-assessment.ts deleted file mode 100644 index 150040b3..00000000 --- a/src/lib/deep-research/review-assessment.ts +++ /dev/null @@ -1,126 +0,0 @@ -// ============================================================= -// Deep Research — Reviewer Deliberation Summary -// ============================================================= - -import * as store from "./event-store"; -import { executeNode } from "./node-executor"; -import type { - DeepResearchArtifact, - ReviewerPacket, - ReviewAssessment, - ReviewAssessmentExtended, - ReviewRound, -} from "./types"; - -export interface ReviewAssessmentConfig { - maxRounds: number; - convergenceThreshold: number; -} - -const DEFAULT_REVIEW_ASSESSMENT_CONFIG: ReviewAssessmentConfig = { - maxRounds: 3, - convergenceThreshold: 0.7, -}; - -/** - * Run a single Results and Evidence Analyst review pass and summarize it into - * a first-class review assessment artifact. - */ -export async function runReviewAssessment( - sessionId: string, - targetArtifacts: DeepResearchArtifact[], - config: ReviewAssessmentConfig = DEFAULT_REVIEW_ASSESSMENT_CONFIG, - abortSignal?: AbortSignal -): Promise<ReviewAssessmentExtended> { - const session = await store.getSession(sessionId); - if (!session) throw new Error(`Session ${sessionId} not found`); - - await store.appendEvent(sessionId, "review_started", undefined, "system", undefined, undefined, { - maxRounds: 1, - reviewerRole: "results_and_evidence_analyst", - }); - - const reviewerNode = await store.createNode(sessionId, { - nodeType: "review", - label: "Results and Evidence Analyst review", - assignedRole: "results_and_evidence_analyst", - input: { - round: 1, - targetArtifactIds: targetArtifacts.map((artifact) => artifact.id), - requestedMaxRounds: config.maxRounds, - }, - contextTag: session.contextTag, - }); - - const ctx = { - session, - messages: await store.getMessages(sessionId), - allNodes: await store.getNodes(sessionId), - allArtifacts: await store.getArtifacts(sessionId), - }; - await executeNode(reviewerNode, ctx, abortSignal); - - const artifacts = await store.getArtifacts(sessionId); - const packetArtifact = artifacts.find((artifact) => - artifact.nodeId === reviewerNode.id && artifact.artifactType === "reviewer_packet" - ); - const packet: ReviewerPacket = packetArtifact - ? (packetArtifact.content as unknown as ReviewerPacket) - : createFallbackPacket(); - - const rounds: ReviewRound[] = [{ round: 1, reviewerPacket: packet }]; - const extendedResult: ReviewAssessmentExtended = { - ...buildReviewAssessment(packet), - rounds, - reviewRounds: 1, - reviewerRole: packet.reviewerRole, - reviewerSummary: packet.critique, - reviewHighlights: packet.suggestions ?? [], - openIssues: packet.identifiedGaps ?? [], - reviewHistory: rounds, - }; - - await store.createArtifact( - sessionId, - null, - "review_assessment", - `Reviewer Assessment (${rounds.length} round)`, - extendedResult as unknown as Record<string, unknown> - ); - - await store.appendEvent(sessionId, "review_completed", undefined, "system", undefined, undefined, { - rounds: 1, - reviewerRole: "results_and_evidence_analyst", - verdict: extendedResult.combinedVerdict, - }); - - return extendedResult; -} - -function buildReviewAssessment(packet: ReviewerPacket): ReviewAssessment { - return { - reviewerSummary: packet.critique, - reviewHighlights: packet.suggestions ?? [], - openIssues: packet.identifiedGaps ?? [], - combinedVerdict: packet.verdict, - combinedConfidence: packet.confidence, - uncertaintyReducers: packet.suggestions ?? [], - needsMoreLiterature: Boolean(packet.identifiedGaps?.length), - literatureGaps: packet.identifiedGaps ?? [], - needsExperimentalValidation: packet.needsExperimentalValidation ?? false, - suggestedExperiments: packet.suggestedExperiments ?? [], - }; -} - -function createFallbackPacket(): ReviewerPacket { - return { - reviewerRole: "results_and_evidence_analyst", - verdict: "revise", - critique: "No review response was produced.", - suggestions: [], - confidence: 0, - identifiedGaps: [], - needsExperimentalValidation: false, - suggestedExperiments: [], - }; -} diff --git a/src/lib/deep-research/session-guards.ts b/src/lib/deep-research/session-guards.ts new file mode 100644 index 00000000..d7b108d4 --- /dev/null +++ b/src/lib/deep-research/session-guards.ts @@ -0,0 +1,83 @@ +import type { DeepResearchArtifact, DeepResearchNode } from "./types"; + +export function canGenerateFinalReport(nodes: DeepResearchNode[]): { allowed: boolean; reason?: string } { + const activePending = nodes.filter((node) => + node.status !== "superseded" && + node.status !== "skipped" && + node.status !== "completed" && + node.status !== "failed" && + node.nodeType !== "final_report" + ); + + if (activePending.length > 0) { + const labels = activePending.slice(0, 5).map((node) => `"${node.label}" (${node.status})`).join(", "); + return { + allowed: false, + reason: `Cannot generate final report: ${activePending.length} required node(s) still pending/running: ${labels}`, + }; + } + + return { allowed: true }; +} + +export function canCompleteSession(nodes: DeepResearchNode[]): { allowed: boolean; reason?: string } { + const activeNodes = nodes.filter((node) => + node.status !== "superseded" && + node.status !== "skipped" && + node.status !== "completed" && + node.status !== "failed" + ); + + if (activeNodes.length > 0) { + const labels = activeNodes.slice(0, 5).map((node) => `"${node.label}" (${node.status})`).join(", "); + return { + allowed: false, + reason: `Cannot complete session: ${activeNodes.length} node(s) still active: ${labels}`, + }; + } + + return { allowed: true }; +} + +export function checkEvidenceSufficiency( + nodes: DeepResearchNode[], + artifacts: DeepResearchArtifact[], +): { + canSynthesize: boolean; + emptyStreams: string[]; + totalSources: number; +} { + const evidenceNodes = nodes.filter((node) => + node.nodeType === "evidence_gather" && + (node.status === "completed" || node.status === "failed") + ); + + let totalSources = 0; + const emptyStreams: string[] = []; + + for (const node of evidenceNodes) { + const nodeArtifacts = artifacts.filter( + (artifact) => artifact.nodeId === node.id && artifact.artifactType === "evidence_card", + ); + if (nodeArtifacts.length === 0) { + emptyStreams.push(node.label); + continue; + } + + const content = nodeArtifacts[0].content; + const sources = (content.sources as unknown[]) ?? (content.claims as unknown[]) ?? []; + const totalFound = (content.totalFound as number) ?? (content.papersFound as number) ?? sources.length; + if (totalFound === 0 && sources.length === 0) { + emptyStreams.push(node.label); + continue; + } + + totalSources += Math.max(totalFound, sources.length); + } + + return { + canSynthesize: totalSources > 0, + emptyStreams, + totalSources, + }; +} diff --git a/src/lib/deep-research/skill-library.ts b/src/lib/deep-research/skill-library.ts deleted file mode 100644 index beb8c369..00000000 --- a/src/lib/deep-research/skill-library.ts +++ /dev/null @@ -1,239 +0,0 @@ -// ============================================================= -// Deep Research — Dynamic Skill / Task Registry -// ============================================================= -// Provides a catalog of available skills the MainBrain can select -// when planning research workflows. Config-gated via skillRouting. - -import type { SkillDefinition, SkillCategory, NodeType, ModelRole } from "./types"; - -// ------------------------------------------------------------------- -// Skill Registry -// ------------------------------------------------------------------- - -export class SkillRegistry { - private skills = new Map<string, SkillDefinition>(); - - register(skill: SkillDefinition): void { - this.skills.set(skill.id, skill); - } - - get(id: string): SkillDefinition | undefined { - return this.skills.get(id); - } - - getByCategory(category: SkillCategory): SkillDefinition[] { - return Array.from(this.skills.values()).filter(s => s.category === category); - } - - getAll(): SkillDefinition[] { - return Array.from(this.skills.values()); - } - - /** - * Return a formatted catalog string for the MainBrain prompt, - * so it can select skills when planning. - */ - describeForLLM(): string { - const categories: SkillCategory[] = ["retrieval", "synthesis", "review", "execution", "report"]; - const sections: string[] = []; - - for (const category of categories) { - const skills = this.getByCategory(category); - if (skills.length === 0) continue; - - const header = `### ${category.charAt(0).toUpperCase() + category.slice(1)} Skills`; - const entries = skills.map(s => - `- **${s.id}** (${s.name}): ${s.description} [nodeType=${s.nodeType}, role=${s.defaultRole}, ~${s.estimatedTokens} tokens]` - ).join("\n"); - - sections.push(`${header}\n${entries}`); - } - - return `## Available Skills Catalog\n\n${sections.join("\n\n")}`; - } -} - -// ------------------------------------------------------------------- -// Default skill registry with pre-registered skills -// ------------------------------------------------------------------- - -function skill( - id: string, - name: string, - description: string, - category: SkillCategory, - nodeType: NodeType, - defaultRole: ModelRole, - estimatedTokens: number, -): SkillDefinition { - return { id, name, description, category, nodeType, defaultRole, estimatedTokens }; -} - -export const defaultSkillRegistry = new SkillRegistry(); - -// --- Retrieval Skills --- -defaultSkillRegistry.register(skill( - "arxiv_search", "arXiv Search", - "Search arXiv for academic papers matching a specific query or topic", - "retrieval", "retrieve", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "hf_papers_search", "HuggingFace Papers Search", - "Search HuggingFace daily papers for recent ML research and model releases", - "retrieval", "retrieve", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "semantic_scholar_search", "Semantic Scholar Search", - "Search Semantic Scholar for papers with citation graph traversal", - "retrieval", "retrieve", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "citation_backtrack", "Citation Backtracking", - "Follow citation chains backward from a known paper to find foundational work", - "retrieval", "retrieve", "worker", 3000, -)); -defaultSkillRegistry.register(skill( - "benchmark_retrieval", "Benchmark Retrieval", - "Find benchmark datasets and leaderboard results for a specific task or method", - "retrieval", "retrieve", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "repo_dataset_discovery", "Repository & Dataset Discovery", - "Find code repositories, datasets, and pre-trained models on GitHub/HuggingFace", - "retrieval", "retrieve", "worker", 2000, -)); - -// --- Synthesis Skills --- -defaultSkillRegistry.register(skill( - "literature_synthesis", "Literature Synthesis", - "Synthesize evidence cards into a coherent literature review with claim mapping", - "synthesis", "synthesize_claims", "synthesizer", 5000, -)); -defaultSkillRegistry.register(skill( - "claim_map_build", "Claim Map Builder", - "Build a structured claim map from evidence: claims, support matrix, contradictions, gaps", - "synthesis", "synthesize_claims", "synthesizer", 4000, -)); -defaultSkillRegistry.register(skill( - "mechanism_synthesis", "Mechanism Synthesis", - "Synthesize evidence into a mechanistic explanation of how/why something works", - "synthesis", "synthesize_claims", "synthesizer", 4000, -)); -defaultSkillRegistry.register(skill( - "contradiction_resolution", "Contradiction Resolution", - "Identify and attempt to resolve contradictions between evidence sources", - "synthesis", "synthesize_claims", "synthesizer", 3000, -)); -defaultSkillRegistry.register(skill( - "gap_analysis", "Gap Analysis", - "Identify evidence gaps and generate targeted queries to fill them", - "synthesis", "synthesize_claims", "synthesizer", 3000, -)); - -// --- Review Skills --- -defaultSkillRegistry.register(skill( - "research_review", "Research Review", - "Research audit covering evidence quality, methodological soundness, and decision readiness.", - "review", "review", "results_and_evidence_analyst", 5000, -)); -defaultSkillRegistry.register(skill( - "experimental_design_review", "Experimental Design Review", - "Review experimental design for methodological soundness, controls, and statistical power", - "review", "review", "results_and_evidence_analyst", 4000, -)); -defaultSkillRegistry.register(skill( - "execution_readiness_review", "Execution Readiness Review", - "Assess whether an experiment plan is ready for actual execution (data, code, resources)", - "review", "review", "results_and_evidence_analyst", 3000, -)); - -// --- Execution Skills --- -defaultSkillRegistry.register(skill( - "cluster_planning", "Cluster Planning", - "Plan GPU/compute resource allocation for an experiment across available clusters", - "execution", "execute", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "data_pipeline_planning", "Data Pipeline Planning", - "Plan data download, preprocessing, and caching pipeline for an experiment", - "execution", "data_download", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "launcher_preparation", "Launcher Preparation", - "Prepare rjob/rlaunch/slurm submission manifests for experiment execution", - "execution", "resource_request", "worker", 1500, -)); -defaultSkillRegistry.register(skill( - "run_monitoring", "Run Monitoring", - "Monitor a running experiment job: status, metrics, logs, and failures", - "execution", "monitor", "worker", 1000, -)); -defaultSkillRegistry.register(skill( - "artifact_collection", "Artifact Collection", - "Collect outputs, metrics, checkpoints, and logs from a completed experiment run", - "execution", "result_collect", "worker", 1500, -)); - -// --- Report Skills --- -defaultSkillRegistry.register(skill( - "final_report", "Final Report", - "Generate comprehensive final research report combining literature, review, and experiment results", - "report", "final_report", "main_brain", 8000, -)); -defaultSkillRegistry.register(skill( - "experiment_spec_writing", "Experiment Spec Writing", - "Write a detailed experiment specification document from a validation plan", - "report", "final_report", "main_brain", 5000, -)); -defaultSkillRegistry.register(skill( - "executive_summary", "Executive Summary", - "Generate a concise executive summary of research findings for stakeholders", - "report", "final_report", "main_brain", 3000, -)); - -// --- Execution Loop Skills (new) --- -defaultSkillRegistry.register(skill( - "execution_planning", "Execution Planning", - "Convert an approved validation plan into executable experiment specs with resource estimates", - "execution", "execute", "main_brain", 3000, -)); -defaultSkillRegistry.register(skill( - "gpu_resource_planning", "GPU Resource Planning", - "Reason about GPU count, type, memory, walltime, and billing for experiment execution", - "execution", "resource_request", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "remote_execution_preparation", "Remote Execution Preparation", - "Prepare SSH-based remote execution: stage files, setup environment, verify launcher availability", - "execution", "execute", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "worker_fanout_design", "Worker Fanout Design", - "Decompose a parent experiment into staged workers for seed sweeps, ablations, hyperparameter search", - "execution", "execute", "main_brain", 3000, -)); -defaultSkillRegistry.register(skill( - "result_validation", "Result Validation", - "Validate experiment outputs against acceptance criteria: metrics, artifacts, worker success rates, variance", - "execution", "result_collect", "worker", 2000, -)); -defaultSkillRegistry.register(skill( - "experiment_failure_analysis", "Experiment Failure Analysis", - "Diagnose failed/inconclusive experiment runs: root cause analysis, resource issues, data problems, hypothesis testing", - "execution", "result_collect", "main_brain", 4000, -)); -defaultSkillRegistry.register(skill( - "replanning_after_execution", "Replanning After Execution", - "Revise experiment plan after execution feedback: adjust resources, change design, narrow scope, or pivot hypothesis", - "execution", "execute", "main_brain", 4000, -)); -defaultSkillRegistry.register(skill( - "result_aggregation", "Result Aggregation", - "Aggregate metrics and artifacts from multiple worker runs into unified experiment results", - "execution", "result_collect", "worker", 1500, -)); -defaultSkillRegistry.register(skill( - "execution_readiness_assessment", "Execution Readiness Assessment", - "Comprehensive readiness check before execution: data availability, resource allocation, environment setup, launcher config", - "execution", "resource_request", "worker", 2000, -)); diff --git a/src/lib/deep-research/slurm-launcher.ts b/src/lib/deep-research/slurm-launcher.ts deleted file mode 100644 index fff7d8fe..00000000 --- a/src/lib/deep-research/slurm-launcher.ts +++ /dev/null @@ -1,186 +0,0 @@ -// ============================================================= -// Deep Research — Slurm Launcher Adapter -// ============================================================= -// Provides Slurm/sbatch job submission support as a fallback -// when rjob is not available. Generates SBATCH scripts and commands. - -import type { - SlurmManifest, - ExecutionConfig, -} from "./types"; -import { DEFAULT_EXECUTION_CONFIG } from "./types"; -import { registerLauncher } from "./execution-adapters"; - -// ------------------------------------------------------------------- -// Options -// ------------------------------------------------------------------- - -export interface SlurmOptions { - jobName: string; - partition?: string; - account?: string; - nodes?: number; - gpusPerNode?: number; - time?: string; - modules?: string[]; - command: string; - outputPath?: string; - errorPath?: string; - purpose: string; - /** Extra SBATCH directives as key-value pairs. */ - extraDirectives?: Record<string, string>; - /** Environment variables to set. */ - env?: Record<string, string>; - /** Working directory. */ - workdir?: string; -} - -// ------------------------------------------------------------------- -// Manifest builder -// ------------------------------------------------------------------- - -/** - * Build a structured Slurm manifest from options + defaults. - */ -export function buildSlurmManifest( - options: SlurmOptions, - config: ExecutionConfig = DEFAULT_EXECUTION_CONFIG, -): SlurmManifest { - return { - launcherType: "slurm", - jobName: options.jobName, - partition: options.partition ?? "gpu", - account: options.account ?? config.defaultChargedGroup ?? "default", - nodes: options.nodes ?? 1, - gpusPerNode: options.gpusPerNode ?? config.defaultResources.gpu, - time: options.time ?? "24:00:00", - modules: options.modules ?? [], - command: options.command, - outputPath: options.outputPath ?? `slurm-%j-${options.jobName}.out`, - errorPath: options.errorPath ?? `slurm-%j-${options.jobName}.err`, - }; -} - -// ------------------------------------------------------------------- -// Script generation -// ------------------------------------------------------------------- - -/** - * Convert a Slurm manifest to a full sbatch script (#!/bin/bash header + #SBATCH directives). - */ -export function slurmToScript(manifest: SlurmManifest): string { - const lines: string[] = []; - - lines.push("#!/bin/bash"); - lines.push(`#SBATCH --job-name=${manifest.jobName ?? "deep-research-job"}`); - lines.push(`#SBATCH --partition=${manifest.partition}`); - lines.push(`#SBATCH --account=${manifest.account}`); - lines.push(`#SBATCH --nodes=${manifest.nodes}`); - lines.push(`#SBATCH --gres=gpu:${manifest.gpusPerNode}`); - lines.push(`#SBATCH --time=${manifest.time}`); - - if (manifest.outputPath) { - lines.push(`#SBATCH --output=${manifest.outputPath}`); - } - if (manifest.errorPath) { - lines.push(`#SBATCH --error=${manifest.errorPath}`); - } - - lines.push(""); - - // Module loads - if (manifest.modules.length > 0) { - lines.push("# Load modules"); - for (const mod of manifest.modules) { - lines.push(`module load ${mod}`); - } - lines.push(""); - } - - // Environment info - lines.push("# Print environment info"); - lines.push("echo \"Job ID: $SLURM_JOB_ID\""); - lines.push("echo \"Node: $SLURM_NODELIST\""); - lines.push("echo \"GPUs: $CUDA_VISIBLE_DEVICES\""); - lines.push("echo \"Start time: $(date)\""); - lines.push(""); - - // Main command - lines.push("# Main command"); - lines.push(manifest.command); - lines.push(""); - lines.push("echo \"End time: $(date)\""); - - return lines.join("\n"); -} - -/** - * Convert a Slurm manifest to an sbatch command invocation. - */ -export function slurmToCommand(manifest: SlurmManifest): string { - const parts = ["sbatch"]; - parts.push(`--job-name=${manifest.jobName ?? "deep-research-job"}`); - parts.push(`--partition=${manifest.partition}`); - parts.push(`--account=${manifest.account}`); - parts.push(`--nodes=${manifest.nodes}`); - parts.push(`--gres=gpu:${manifest.gpusPerNode}`); - parts.push(`--time=${manifest.time}`); - - if (manifest.outputPath) { - parts.push(`--output=${manifest.outputPath}`); - } - if (manifest.errorPath) { - parts.push(`--error=${manifest.errorPath}`); - } - - // For inline commands, wrap in --wrap - parts.push(`--wrap="${manifest.command.replace(/"/g, '\\"')}"`); - - return parts.join(" \\\n "); -} - -/** - * Generate an srun command for interactive/allocation-based execution. - */ -export function slurmToSrun(manifest: SlurmManifest): string { - const parts = ["srun"]; - parts.push(`--partition=${manifest.partition}`); - parts.push(`--account=${manifest.account}`); - parts.push(`--nodes=${manifest.nodes}`); - parts.push(`--gres=gpu:${manifest.gpusPerNode}`); - parts.push(`--time=${manifest.time}`); - parts.push(`--job-name=${manifest.jobName ?? "deep-research-job"}`); - parts.push(manifest.command); - - return parts.join(" \\\n "); -} - -/** - * Generate an salloc command for resource allocation. - */ -export function slurmToSalloc(manifest: SlurmManifest): string { - const parts = ["salloc"]; - parts.push(`--partition=${manifest.partition}`); - parts.push(`--account=${manifest.account}`); - parts.push(`--nodes=${manifest.nodes}`); - parts.push(`--gres=gpu:${manifest.gpusPerNode}`); - parts.push(`--time=${manifest.time}`); - parts.push(`--job-name=${manifest.jobName ?? "deep-research-job"}`); - - return parts.join(" \\\n "); -} - -// ------------------------------------------------------------------- -// Launcher registration -// ------------------------------------------------------------------- - -// Register the Slurm launcher in the global launcher registry -// Note: We cast to satisfy the LauncherAdapter interface which uses LauncherType. -// The SlurmManifest is a valid ExecutionManifest after types.ts update. -registerLauncher({ - type: "slurm" as unknown as "rlaunch", - label: "sbatch (Slurm)", - description: "Submit a batch job via Slurm scheduler (sbatch/srun fallback)", - buildManifest: (opts, config) => buildSlurmManifest(opts as unknown as SlurmOptions, config) as unknown as import("./types").ExecutionManifest, - toCommand: (m) => slurmToCommand(m as unknown as SlurmManifest), -}); diff --git a/src/lib/deep-research/stale-detector.ts b/src/lib/deep-research/stale-detector.ts deleted file mode 100644 index 4e88e43f..00000000 --- a/src/lib/deep-research/stale-detector.ts +++ /dev/null @@ -1,82 +0,0 @@ -// ============================================================= -// Deep Research — Stale Plan Detector -// ============================================================= - -import type { DeepResearchNode, RequirementDiff } from "./types"; -import * as store from "./event-store"; - -/** - * Detect nodes that are stale due to requirement changes. - * A node is stale if: - * - It is in "pending" or "queued" status - * - Its requirementVersion (tracked via creation time) is older than current - * - The diff affects requirements relevant to the node's task - */ -export function detectStaleNodes( - nodes: DeepResearchNode[], - oldReqVersion: number, - newReqVersion: number, - diff: RequirementDiff -): string[] { - if (oldReqVersion === newReqVersion) return []; - if (diff.added.length === 0 && diff.removed.length === 0 && diff.modified.length === 0) { - return []; - } - - const staleIds: string[] = []; - const actionableStatuses = new Set(["pending", "queued"]); - - for (const node of nodes) { - if (!actionableStatuses.has(node.status)) continue; - if (node.status === "superseded") continue; - - // If requirements were removed or modified, pending plan/evidence nodes are stale - if ( - node.nodeType === "plan" || - node.nodeType === "evidence_gather" || - node.nodeType === "evidence_extract" || - node.nodeType === "summarize" || - node.nodeType === "synthesize" - ) { - // Check if removed/modified requirements overlap with node's scope - const hasRelevantChanges = - diff.removed.length > 0 || - diff.modified.some((m) => m.field === "text" || m.field === "status"); - - if (hasRelevantChanges) { - staleIds.push(node.id); - } - } - } - - return staleIds; -} - -/** - * Mark nodes as superseded and create audit events. - */ -export async function supersedePlan( - sessionId: string, - staleNodeIds: string[], - reason: string -): Promise<void> { - if (staleNodeIds.length === 0) return; - - for (const nodeId of staleNodeIds) { - await store.updateNode(nodeId, { status: "superseded" }); - } - - await store.appendEvent( - sessionId, - "nodes_superseded", - undefined, - "system", - undefined, - undefined, - { - nodeIds: staleNodeIds, - reason, - count: staleNodeIds.length, - } - ); -} diff --git a/src/lib/deep-research/status-types.ts b/src/lib/deep-research/status-types.ts new file mode 100644 index 00000000..0c5bacce --- /dev/null +++ b/src/lib/deep-research/status-types.ts @@ -0,0 +1,155 @@ +// ============================================================= +// Deep Research — Status / Identity / Artifact unions +// ============================================================= + +export type SessionStatus = + | "intake" + | "planning" + | "running" + | "paused" + | "awaiting_approval" + | "awaiting_user_confirmation" + | "awaiting_resource" + | "reviewing" + | "planning_in_progress" + | "literature_in_progress" + | "literature_blocked" + | "awaiting_additional_literature" + | "validation_planning_in_progress" + | "execution_prepared" + | "execution_in_progress" + | "final_report_generated" + | "completed" + | "stopped_by_user" + | "failed" + | "cancelled"; + +export type ContextTag = + | "intake" + | "planning" + | "final_report"; + +export const VALID_CONTEXT_TAGS: readonly ContextTag[] = [ + "intake", + "planning", + "final_report", +]; + +export type NodeType = + | "intake" + | "plan" + | "evidence_gather" + | "evidence_extract" + | "summarize" + | "synthesize" + | "review" + | "audit" + | "validation_plan" + | "resource_request" + | "execute" + | "monitor" + | "result_collect" + | "result_compare" + | "approve" + | "final_report" + | "retrieve" + | "synthesize_claims" + | "data_download" + | "preprocess" + | "skill_route"; + +export type NodeStatus = + | "pending" + | "queued" + | "running" + | "completed" + | "failed" + | "skipped" + | "awaiting_approval" + | "awaiting_user_confirmation" + | "superseded"; + +export type ModelRole = + | "main_brain" + | "researcher" + | "literature_intelligence_analyst" + | "experiment_architecture_designer" + | "research_software_engineer" + | "experiment_operations_engineer" + | "results_and_evidence_analyst" + | "research_asset_reuse_specialist" + | "worker" + | "synthesizer"; + +export type ArtifactType = + | "research_brief" + | "task_graph" + | "evidence_card" + | "literature_round_summary" + | "structured_summary" + | "reviewer_packet" + | "review_assessment" + | "main_brain_audit" + | "provisional_conclusion" + | "validation_plan" + | "execution_manifest" + | "execution_plan" + | "step_result" + | "experiment_result" + | "validation_report" + | "final_report" + | "checkpoint" + | "evidence_card_collection" + | "claim_map" + | "memory_profile" + | "memory_snapshot" + | "memory_index" + | "data_manifest"; + +export type EventType = + | "session_created" + | "node_created" + | "node_started" + | "node_completed" + | "node_failed" + | "artifact_created" + | "user_message" + | "brain_response" + | "approval_requested" + | "approval_granted" + | "approval_denied" + | "session_completed" + | "session_failed" + | "checkpoint_created" + | "confirmation_requested" + | "user_confirmed" + | "user_requested_revision" + | "user_requested_branch" + | "user_rejected_result" + | "user_requested_stop" + | "user_approved_execution" + | "user_approved_remote_submission" + | "literature_round_started" + | "literature_round_completed" + | "review_started" + | "review_completed" + | "execution_submitted" + | "execution_completed" + | "resource_requested" + | "resource_acquired" + | "requirement_changed" + | "nodes_superseded" + | "consistency_check" + | "skill_routing_completed" + | "synthesis_completed" + | "execution_plan_created" + | "data_download_completed"; + +export type MessageRole = "user" | "main_brain" | "system"; + +export type ConfirmationOutcome = + | "confirmed" + | "revision_requested" + | "branch_requested" + | "rejected" + | "stopped"; diff --git a/src/lib/deep-research/structured-types.ts b/src/lib/deep-research/structured-types.ts new file mode 100644 index 00000000..b7cd117c --- /dev/null +++ b/src/lib/deep-research/structured-types.ts @@ -0,0 +1,108 @@ +import type { + ArtifactType, + ContextTag, + ModelRole, + NodeType, +} from "./status-types"; + +export type StructuredRoleCategory = "main_brain" | "meta_worker"; + +export type StructuredPromptKind = + | "system" + | "task_intake" + | "progress_update" + | "handoff" + | "escalation" + | "completion"; + +export type StructuredSkillKind = + | "literature_analysis" + | "experiment_design" + | "code_implementation" + | "experiment_execution" + | "result_analysis" + | "artifact_packaging" + | "coordination"; + +export interface StructuredRolePrompt { + kind: StructuredPromptKind; + title: string; + objective: string; + requiredSections: string[]; + constraints: string[]; +} + +export interface StructuredRoleSkill { + id: string; + kind: StructuredSkillKind; + name: string; + purpose: string; + inputs: string[]; + outputs: string[]; + qualityChecks: string[]; +} + +export interface StructuredRoleCollaboration { + partnerRoleId: ModelRole; + collaborationType: "delegation" | "handoff" | "review" | "feedback" | "escalation" | "reuse"; + trigger: string; + payload: string[]; + expectedResponse: string[]; +} + +export interface StructuredRoleDefinition { + roleId: ModelRole; + category: StructuredRoleCategory; + roleName: string; + workflowSegment: string; + defaultNodeType: NodeType; + defaultContextTag: ContextTag; + summaryArtifactType: ArtifactType; + corePositioning: string; + coreResponsibilities: string[]; + skillRequirements: string[]; + collaborationRequirements: string[]; + performanceStandards: string[]; + prompts: StructuredRolePrompt[]; + skills: StructuredRoleSkill[]; + collaborations: StructuredRoleCollaboration[]; +} + +export interface StructuredCommunicationProtocol { + id: string; + fromRoleId: ModelRole; + toRoleId: ModelRole; + goal: string; + trigger: string; + requiredPayload: string[]; + responseContract: string[]; + escalationPath: string; +} + +export interface StructuredTaskAssignment { + roleId: ModelRole; + roleName: string; + workflowSegment: string; + objective: string; + deliverables: string[]; + dependencies: ModelRole[]; + status: "planned" | "in_progress" | "blocked" | "completed"; +} + +export interface StructuredTaskBoard { + objective: string; + coordinatorRoleId: ModelRole; + assignments: StructuredTaskAssignment[]; + milestones: string[]; + completionCriteria: string[]; +} + +export interface StructuredHandoffPacket { + type: "handoff" | "progress_update" | "escalation"; + fromRoleId: ModelRole; + toRoleId: ModelRole; + goal: string; + payload: string[]; + expectedResponse: string[]; + status: "drafted" | "shared" | "acknowledged"; +} diff --git a/src/lib/deep-research/summary-packets.test.ts b/src/lib/deep-research/summary-packets.test.ts new file mode 100644 index 00000000..f85c96bf --- /dev/null +++ b/src/lib/deep-research/summary-packets.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { + buildClaimMapFromStructuredSummary, + normalizeStructuredSummaryArtifact, + selectChapterPacketsForSection, +} from "./summary-packets"; +import type { DeepResearchArtifact } from "./types"; + +function createArtifact(overrides: Partial<DeepResearchArtifact>): DeepResearchArtifact { + return { + id: overrides.id ?? "artifact-1", + sessionId: overrides.sessionId ?? "session-1", + nodeId: overrides.nodeId ?? "node-1", + artifactType: overrides.artifactType ?? "evidence_card", + title: overrides.title ?? "Artifact", + content: overrides.content ?? {}, + provenance: overrides.provenance ?? null, + version: overrides.version ?? 1, + createdAt: overrides.createdAt ?? "2026-04-17T00:00:00.000Z", + }; +} + +describe("summary packets", () => { + it("falls back to evidence-card derived chapter packets when summarize output is unstructured", () => { + const summary = normalizeStructuredSummaryArtifact({ + rawOutput: { text: "plain text summary" }, + parentArtifacts: [ + createArtifact({ + title: "Evidence: sparse attention", + content: { + query: "稀疏注意力技术路线", + coverageSummary: "Sparse attention methods reduce long-sequence complexity.", + sources: [ + { + title: "Informer", + url: "https://arxiv.org/abs/2012.07436", + year: 2021, + retrievalMethod: "search", + retrievedAt: "2026-04-17T00:00:00.000Z", + }, + ], + rawExcerpts: [ + { text: "ProbSparse selects dominant queries for efficient long sequence forecasting.", sourceIndex: 0 }, + ], + }, + }), + ], + label: "时间序列综述", + }); + + expect(summary.chapterPackets).toHaveLength(1); + expect(summary.chapterPackets[0]?.title).toContain("稀疏注意力"); + expect(summary.chapterPackets[0]?.citationKeys).toContain("Informer, 2021"); + expect(summary.chapterPackets[0]?.supportingQuotes[0]?.citationKey).toBe("Informer, 2021"); + }); + + it("builds a claim map from chapter packets", () => { + const summary = normalizeStructuredSummaryArtifact({ + rawOutput: { + summary: "overall summary", + chapterPackets: [ + { + id: "chapter_1", + title: "架构谱系", + objective: "梳理技术路线", + summary: "Summarize architecture families.", + claims: [ + { + id: "claim_1", + text: "Informer opened the sparse-attention line.", + strength: "strong", + citationKeys: ["Informer, 2021"], + supportingSourceTitles: ["Informer"], + counterpoints: [], + }, + ], + supportingQuotes: [], + citationKeys: ["Informer, 2021"], + keyTakeaways: ["sparse attention mattered"], + openQuestions: ["how to unify later architectures"], + recommendedSectionText: "Section seed [Informer, 2021].", + }, + ], + }, + parentArtifacts: [ + createArtifact({ + content: { + sources: [ + { + title: "Informer", + url: "https://arxiv.org/abs/2012.07436", + year: 2021, + retrievalMethod: "search", + retrievedAt: "2026-04-17T00:00:00.000Z", + }, + ], + }, + }), + ], + label: "时间序列综述", + }); + + const claimMap = buildClaimMapFromStructuredSummary(summary, [ + createArtifact({ + content: { + sources: [ + { + title: "Informer", + url: "https://arxiv.org/abs/2012.07436", + year: 2021, + retrievalMethod: "search", + retrievedAt: "2026-04-17T00:00:00.000Z", + }, + ], + }, + }), + ]); + + expect(claimMap.claims).toHaveLength(1); + expect(claimMap.claims[0]?.supportingSources).toEqual([0]); + expect(claimMap.gaps[0]?.topic).toContain("unify"); + }); + + it("selects the most relevant chapter packets for a section", () => { + const packets = selectChapterPacketsForSection({ + sectionTitle: "稀疏注意力技术演进", + sectionSummary: "分析 ProbSparse 与频域分解", + citationFocus: ["sparse attention", "Informer", "FEDformer"], + chapterPackets: [ + { + id: "chapter_1", + title: "稀疏注意力路线", + objective: "梳理 Informer 和 FEDformer", + summary: "Sparse attention and frequency-domain methods.", + keyTakeaways: [], + claims: [], + supportingQuotes: [], + citationKeys: ["Informer, 2021", "FEDformer, 2022"], + openQuestions: [], + recommendedSectionText: "", + }, + { + id: "chapter_2", + title: "反向嵌入路线", + objective: "梳理 PatchTST 和 iTransformer", + summary: "Inverted embedding and channel-first views.", + keyTakeaways: [], + claims: [], + supportingQuotes: [], + citationKeys: ["PatchTST, 2023", "iTransformer, 2024"], + openQuestions: [], + recommendedSectionText: "", + }, + ], + }); + + expect(packets[0]?.title).toBe("稀疏注意力路线"); + }); +}); diff --git a/src/lib/deep-research/summary-packets.ts b/src/lib/deep-research/summary-packets.ts new file mode 100644 index 00000000..ddd27d8d --- /dev/null +++ b/src/lib/deep-research/summary-packets.ts @@ -0,0 +1,450 @@ +import type { + ChapterPacket, + ChapterPacketClaim, + ChapterPacketQuote, + Claim, + ClaimMap, + ClaimStrength, + DeepResearchArtifact, + RawExcerpt, + SourceEntry, + StructuredSummaryArtifactContent, +} from "./types"; + +function normalizeText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeStringArray(value: unknown, limit = 8): string[] { + if (!Array.isArray(value)) { + return []; + } + + const collected: string[] = []; + const seen = new Set<string>(); + for (const item of value) { + if (typeof item !== "string") { + continue; + } + const normalized = item.trim(); + if (!normalized || seen.has(normalized)) { + continue; + } + seen.add(normalized); + collected.push(normalized); + if (collected.length >= limit) { + break; + } + } + + return collected; +} + +function citationKeyFromSource(source: Partial<SourceEntry>): string | null { + const title = normalizeText(source.title); + if (!title) { + return null; + } + + return typeof source.year === "number" ? `${title}, ${source.year}` : title; +} + +function normalizeClaimStrength(value: unknown): ClaimStrength { + switch (value) { + case "strong": + case "moderate": + case "weak": + case "unsupported": + return value; + default: + return "moderate"; + } +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function truncateText(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + + return `${text.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`; +} + +function normalizeQuote(raw: unknown): ChapterPacketQuote | null { + if (!isRecord(raw)) { + return null; + } + + const quote = normalizeText(raw.quote ?? raw.text); + const sourceTitle = normalizeText(raw.sourceTitle ?? raw.title); + const citationKey = normalizeText(raw.citationKey); + if (!quote || !sourceTitle || !citationKey) { + return null; + } + + return { + citationKey, + sourceTitle, + quote, + relevance: normalizeText(raw.relevance) || "Supports the section's core analytical point.", + year: typeof raw.year === "number" ? raw.year : undefined, + url: normalizeText(raw.url) || undefined, + }; +} + +function normalizeChapterClaim(raw: unknown, fallbackIndex: number): ChapterPacketClaim | null { + if (!isRecord(raw)) { + return null; + } + + const text = normalizeText(raw.text ?? raw.claim); + if (!text) { + return null; + } + + return { + id: normalizeText(raw.id) || `claim_${fallbackIndex + 1}`, + text, + strength: normalizeClaimStrength(raw.strength), + citationKeys: normalizeStringArray(raw.citationKeys, 6), + supportingSourceTitles: normalizeStringArray(raw.supportingSourceTitles ?? raw.sources, 6), + counterpoints: normalizeStringArray(raw.counterpoints, 4), + }; +} + +function normalizeChapterPacket(raw: unknown, index: number): ChapterPacket | null { + if (!isRecord(raw)) { + return null; + } + + const title = normalizeText(raw.title); + if (!title) { + return null; + } + + const supportingQuotes = Array.isArray(raw.supportingQuotes) + ? raw.supportingQuotes + .map((quote) => normalizeQuote(quote)) + .filter((quote): quote is ChapterPacketQuote => Boolean(quote)) + .slice(0, 6) + : []; + const claims = Array.isArray(raw.claims) + ? raw.claims + .map((claim, claimIndex) => normalizeChapterClaim(claim, claimIndex)) + .filter((claim): claim is ChapterPacketClaim => Boolean(claim)) + .slice(0, 8) + : []; + const citationKeys = new Set<string>(normalizeStringArray(raw.citationKeys, 12)); + for (const quote of supportingQuotes) { + if (quote.citationKey) { + citationKeys.add(quote.citationKey); + } + } + for (const claim of claims) { + for (const citationKey of claim.citationKeys) { + citationKeys.add(citationKey); + } + } + + return { + id: normalizeText(raw.id) || `chapter_${index + 1}`, + title, + objective: normalizeText(raw.objective) || title, + summary: normalizeText(raw.summary) || normalizeText(raw.objective) || title, + keyTakeaways: normalizeStringArray(raw.keyTakeaways, 6), + claims, + supportingQuotes, + citationKeys: [...citationKeys], + openQuestions: normalizeStringArray(raw.openQuestions, 6), + recommendedSectionText: normalizeText(raw.recommendedSectionText), + }; +} + +function collectEvidenceSources(artifacts: DeepResearchArtifact[]): Array<SourceEntry & { citationKey: string }> { + const collected: Array<SourceEntry & { citationKey: string }> = []; + const seen = new Set<string>(); + + for (const artifact of artifacts) { + if (artifact.artifactType !== "evidence_card" || !Array.isArray(artifact.content.sources)) { + continue; + } + + for (const source of artifact.content.sources) { + if (!isRecord(source)) { + continue; + } + + const citationKey = citationKeyFromSource(source as Partial<SourceEntry>); + if (!citationKey || seen.has(citationKey)) { + continue; + } + seen.add(citationKey); + + collected.push({ + title: normalizeText(source.title), + url: normalizeText(source.url), + authors: Array.isArray(source.authors) + ? source.authors.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : undefined, + year: typeof source.year === "number" ? source.year : undefined, + venue: normalizeText(source.venue) || undefined, + doi: normalizeText(source.doi) || undefined, + retrievalMethod: normalizeText(source.retrievalMethod) || "artifact", + retrievedAt: normalizeText(source.retrievedAt) || new Date().toISOString(), + citationKey, + }); + } + } + + return collected; +} + +function collectRepresentativeExcerpts( + artifact: DeepResearchArtifact, + sources: Array<SourceEntry & { citationKey: string }>, +): ChapterPacketQuote[] { + const rawExcerpts = Array.isArray(artifact.content.rawExcerpts) ? artifact.content.rawExcerpts as RawExcerpt[] : []; + const normalized: ChapterPacketQuote[] = []; + + for (const excerpt of rawExcerpts.slice(0, 4)) { + const source = sources[excerpt.sourceIndex]; + if (!source || !excerpt.text?.trim()) { + continue; + } + + normalized.push({ + citationKey: source.citationKey, + sourceTitle: source.title, + quote: truncateText(excerpt.text.trim(), 240), + relevance: [excerpt.section, excerpt.page].filter(Boolean).join(" - ") || "Representative excerpt", + year: source.year, + url: source.url, + }); + } + + return normalized; +} + +function buildFallbackChapterPackets( + artifacts: DeepResearchArtifact[], + label: string, + fallbackSummaryText = "", +): ChapterPacket[] { + const evidenceArtifacts = artifacts.filter((artifact) => artifact.artifactType === "evidence_card"); + const packets = evidenceArtifacts.map((artifact, index) => { + const query = normalizeText(artifact.content.query) || artifact.title; + const sources = Array.isArray(artifact.content.sources) + ? artifact.content.sources + .filter((source): source is Record<string, unknown> => isRecord(source)) + .slice(0, 6) + .map((source) => ({ + title: normalizeText(source.title), + url: normalizeText(source.url), + year: typeof source.year === "number" ? source.year : undefined, + venue: normalizeText(source.venue) || undefined, + doi: normalizeText(source.doi) || undefined, + retrievalMethod: normalizeText(source.retrievalMethod) || "artifact", + retrievedAt: normalizeText(source.retrievedAt) || new Date().toISOString(), + citationKey: citationKeyFromSource(source as Partial<SourceEntry>) || normalizeText(source.title), + })) + : []; + const citationKeys = sources + .map((source) => source.citationKey) + .filter((citationKey): citationKey is string => Boolean(citationKey)) + .slice(0, 6); + const supportingQuotes = collectRepresentativeExcerpts(artifact, sources); + const coverageSummary = normalizeText(artifact.content.coverageSummary); + const recommendedSectionText = [ + coverageSummary || `This section synthesizes the evidence gathered for "${query}".`, + sources.length > 0 + ? `Representative works include ${sources.slice(0, 3).map((source) => `[${source.citationKey}]`).join(", ")}.` + : "The evidence base is thin and should be treated cautiously.", + ].join(" "); + + return { + id: `chapter_${index + 1}`, + title: query.length > 80 ? `${query.slice(0, 77)}...` : query, + objective: query, + summary: coverageSummary || query, + keyTakeaways: sources.slice(0, 4).map((source) => source.title), + claims: [ + { + id: `claim_${index + 1}`, + text: coverageSummary || `Evidence relevant to "${query}" was collected from the cited literature.`, + strength: sources.length >= 3 ? "strong" : sources.length >= 1 ? "moderate" : "weak", + citationKeys, + supportingSourceTitles: sources.slice(0, 4).map((source) => source.title), + counterpoints: [], + }, + ], + supportingQuotes, + citationKeys, + openQuestions: [], + recommendedSectionText, + } satisfies ChapterPacket; + }); + + if (packets.length > 0) { + return packets; + } + + return [{ + id: "chapter_1", + title: label, + objective: label, + summary: fallbackSummaryText || `Synthesis for ${label}`, + keyTakeaways: [], + claims: [{ + id: "claim_1", + text: fallbackSummaryText || `The available evidence has been summarized for ${label}.`, + strength: "weak", + citationKeys: [], + supportingSourceTitles: [], + counterpoints: [], + }], + supportingQuotes: [], + citationKeys: [], + openQuestions: [], + recommendedSectionText: fallbackSummaryText, + }]; +} + +export function normalizeStructuredSummaryArtifact(input: { + rawOutput: Record<string, unknown>; + parentArtifacts: DeepResearchArtifact[]; + label: string; +}): StructuredSummaryArtifactContent { + const fallbackSummaryText = normalizeText(input.rawOutput.summary) || normalizeText(input.rawOutput.text); + const normalizedPackets = Array.isArray(input.rawOutput.chapterPackets) + ? input.rawOutput.chapterPackets + .map((packet, index) => normalizeChapterPacket(packet, index)) + .filter((packet): packet is ChapterPacket => Boolean(packet)) + : []; + const chapterPackets = normalizedPackets.length > 0 + ? normalizedPackets + : buildFallbackChapterPackets(input.parentArtifacts, input.label, fallbackSummaryText); + const citationKeys = new Set<string>(); + for (const packet of chapterPackets) { + for (const citationKey of packet.citationKeys) { + citationKeys.add(citationKey); + } + } + + return { + summary: fallbackSummaryText + || chapterPackets.map((packet) => `${packet.title}: ${packet.summary}`).join(" "), + chapterPackets, + crossSectionThemes: normalizeStringArray(input.rawOutput.crossSectionThemes, 8), + globalOpenQuestions: normalizeStringArray(input.rawOutput.globalOpenQuestions, 8), + citationKeys: [...citationKeys], + recommendedReportNarrative: normalizeText(input.rawOutput.recommendedReportNarrative) || undefined, + }; +} + +export function buildClaimMapFromStructuredSummary( + summary: StructuredSummaryArtifactContent, + parentArtifacts: DeepResearchArtifact[], +): ClaimMap { + const globalSources = collectEvidenceSources(parentArtifacts); + const citationIndex = new Map(globalSources.map((source, index) => [source.citationKey, index])); + const claims: Claim[] = []; + const supportMatrix: Record<string, number[]> = {}; + const gaps = summary.chapterPackets + .flatMap((packet) => packet.openQuestions.map((topic) => ({ + topic, + description: `Open question in ${packet.title}`, + suggestedQueries: [packet.objective], + priority: "medium" as const, + }))); + + for (const packet of summary.chapterPackets) { + for (const packetClaim of packet.claims) { + const supportingSources = packetClaim.citationKeys + .map((citationKey) => citationIndex.get(citationKey)) + .filter((index): index is number => typeof index === "number"); + claims.push({ + id: packetClaim.id, + text: packetClaim.text, + strength: packetClaim.strength, + supportingSources, + contradictingSources: [], + category: packet.title, + knowledgeType: supportingSources.length > 0 ? "retrieved_evidence" : "assumption", + }); + supportMatrix[packetClaim.id] = supportingSources; + } + } + + const confidenceDistribution: Record<ClaimStrength, number> = { + strong: 0, + moderate: 0, + weak: 0, + unsupported: 0, + }; + for (const claim of claims) { + confidenceDistribution[claim.strength] += 1; + } + + return { + claims, + supportMatrix, + contradictions: [], + gaps, + confidenceDistribution, + }; +} + +export function extractChapterPacketsFromArtifacts( + artifacts: DeepResearchArtifact[], +): ChapterPacket[] { + const packets: ChapterPacket[] = []; + + for (const artifact of artifacts) { + if (artifact.artifactType !== "structured_summary" || !Array.isArray(artifact.content.chapterPackets)) { + continue; + } + + for (const rawPacket of artifact.content.chapterPackets) { + const normalized = normalizeChapterPacket(rawPacket, packets.length); + if (!normalized) { + continue; + } + packets.push(normalized); + } + } + + return packets; +} + +export function selectChapterPacketsForSection(input: { + sectionTitle: string; + sectionSummary: string; + citationFocus: string[]; + chapterPackets: ChapterPacket[]; + limit?: number; +}): ChapterPacket[] { + const query = `${input.sectionTitle} ${input.sectionSummary} ${input.citationFocus.join(" ")}`.toLowerCase(); + const queryTokens = new Set(query.split(/[^a-z0-9\u4e00-\u9fff]+/i).filter(Boolean)); + if (queryTokens.size === 0) { + return input.chapterPackets.slice(0, input.limit ?? 2); + } + + return [...input.chapterPackets] + .map((packet) => { + const packetText = `${packet.title} ${packet.objective} ${packet.summary} ${packet.keyTakeaways.join(" ")} ${packet.citationKeys.join(" ")}`.toLowerCase(); + const packetTokens = packetText.split(/[^a-z0-9\u4e00-\u9fff]+/i).filter(Boolean); + let score = 0; + for (const token of packetTokens) { + if (queryTokens.has(token)) { + score += 1; + } + } + return { packet, score }; + }) + .sort((left, right) => right.score - left.score || left.packet.title.localeCompare(right.packet.title)) + .slice(0, input.limit ?? 2) + .map((item) => item.packet); +} diff --git a/src/lib/deep-research/synthesizer.ts b/src/lib/deep-research/synthesizer.ts deleted file mode 100644 index 1abda309..00000000 --- a/src/lib/deep-research/synthesizer.ts +++ /dev/null @@ -1,384 +0,0 @@ -// ============================================================= -// Deep Research — Results and Evidence Analysis Synthesis -// ============================================================= -// Dedicated synthesis: reads evidence cards, builds structured claim -// maps, identifies gaps. Replaces the pattern where evidence_gather -// nodes do search+synthesis together. - -import { generateText } from "ai"; -import { getModelForRole, checkBudget, trackUsage } from "./model-router"; -import * as store from "./event-store"; -import type { - DeepResearchSession, - DeepResearchArtifact, - EvidenceCardCollection, - ClaimMap, - RequirementState, - ArtifactProvenance, - ReviewRevisionRequest, -} from "./types"; -import { evidenceCardToMarkdown } from "./evidence-cards"; - -// ------------------------------------------------------------------- -// Prompt builder -// ------------------------------------------------------------------- - -export function buildSynthesizerPrompt( - cards: EvidenceCardCollection, - requirementState?: RequirementState | null, -): string { - const cardsMarkdown = cards.cards.map(c => evidenceCardToMarkdown(c)).join("\n\n---\n\n"); - - const requirementSection = requirementState - ? `\n## Research Requirements\n- Goal: ${requirementState.currentApprovedGoal}\n- Active requirements: ${requirementState.requirements.filter(r => r.status === "active").map(r => r.text).join("; ")}` - : ""; - - return `You are operating as the Results and Evidence Analyst. Your job is to read evidence cards and produce a structured ClaimMap. - -## STRICT RULES -1. Build claims ONLY from the evidence provided below. Do NOT fabricate. -2. For each claim, classify its strength: strong (multiple independent sources), moderate (1-2 sources), weak (single source with caveats), unsupported (no direct evidence). -3. Map each claim to its supporting source indices. -4. Identify contradictions between sources explicitly. -5. Identify evidence GAPS — topics where evidence is missing or insufficient. -6. For every claim, distinguish its knowledge type: - - "retrieved_evidence": directly supported by a source below - - "background_knowledge": general domain knowledge not from these sources - - "assumption": reasonable assumption not directly evidenced - - "speculation": forward-looking inference beyond the evidence - -## Evidence Cards (${cards.totalSources} sources, ${cards.totalExcerpts} excerpts) - -### Retrieval Summary -- Successful retrievals: ${cards.retrievalSummary.successful} -- Partial retrievals: ${cards.retrievalSummary.partial} -- Failed retrievals: ${cards.retrievalSummary.failed} -- Empty retrievals: ${cards.retrievalSummary.empty} - -${cardsMarkdown} -${requirementSection} - -## Output Format -Respond with valid JSON matching the ClaimMap schema: -{ - "claims": [ - { - "id": "c1", - "text": "Claim text", - "strength": "strong|moderate|weak|unsupported", - "supportingSources": [0, 2], - "contradictingSources": [], - "category": "topic category", - "knowledgeType": "retrieved_evidence|background_knowledge|assumption|speculation" - } - ], - "supportMatrix": { "c1": [0, 2], "c2": [1] }, - "contradictions": [ - { "claimAId": "c1", "claimBId": "c3", "description": "...", "possibleResolution": "..." } - ], - "gaps": [ - { "topic": "...", "description": "...", "suggestedQueries": ["..."], "priority": "high|medium|low" } - ], - "confidenceDistribution": { "strong": 3, "moderate": 5, "weak": 2, "unsupported": 1 } -} - -Be thorough. Missing a contradiction or gap is worse than including a weak claim.`; -} - -// ------------------------------------------------------------------- -// Execute synthesis -// ------------------------------------------------------------------- - -export async function executeSynthesis( - session: DeepResearchSession, - evidenceCards: EvidenceCardCollection, - abortSignal?: AbortSignal, -): Promise<{ - claimMap: ClaimMap; - artifacts: DeepResearchArtifact[]; -}> { - // Use the results-and-evidence role for synthesis work. - const { model } = getModelForRole("results_and_evidence_analyst", session.config); - - const budgetCheck = checkBudget("results_and_evidence_analyst", session.budget, session.config.budget); - if (!budgetCheck.allowed) { - throw new Error(`Results and Evidence Analyst budget exceeded: ${budgetCheck.reason}`); - } - - // Create synthesize_claims node - const synthNode = await store.createNode(session.id, { - nodeType: "synthesize_claims", - label: "Results and Evidence Analyst: Build claim map from evidence cards", - assignedRole: "results_and_evidence_analyst", - input: { - totalCards: evidenceCards.cards.length, - totalSources: evidenceCards.totalSources, - retrievalSummary: evidenceCards.retrievalSummary, - }, - contextTag: "planning", - }); - - await store.updateNode(synthNode.id, { - status: "running", - startedAt: new Date().toISOString(), - }); - - try { - const prompt = buildSynthesizerPrompt(evidenceCards); - - const result = await generateText({ - model, - system: "You are operating as the Results and Evidence Analyst. Produce a structured ClaimMap from evidence. Respond ONLY with valid JSON.", - messages: [{ role: "user", content: prompt }], - abortSignal, - }); - - const tokens = result.usage?.totalTokens ?? 0; - const budget = trackUsage(session.budget, "results_and_evidence_analyst", synthNode.id, tokens); - await store.updateSession(session.id, { budget }); - - // Parse claim map - const claimMap = parseClaimMap(result.text); - - // Mark node completed - await store.updateNode(synthNode.id, { - status: "completed", - output: claimMap as unknown as Record<string, unknown>, - completedAt: new Date().toISOString(), - }); - - // Create claim_map artifact - const provenance: ArtifactProvenance = { - sourceNodeId: synthNode.id, - sourceArtifactIds: [], - model: "results_and_evidence_analyst", - generatedAt: new Date().toISOString(), - }; - - const artifact = await store.createArtifact( - session.id, - synthNode.id, - "claim_map", - `Claim Map (${claimMap.claims.length} claims, ${claimMap.contradictions.length} contradictions, ${claimMap.gaps.length} gaps)`, - claimMap as unknown as Record<string, unknown>, - provenance, - ); - - await store.appendEvent(session.id, "synthesis_completed", synthNode.id, "results_and_evidence_analyst", undefined, undefined, { - claimsCount: claimMap.claims.length, - contradictionsCount: claimMap.contradictions.length, - gapsCount: claimMap.gaps.length, - }); - - return { claimMap, artifacts: [artifact] }; - } catch (error) { - const message = error instanceof Error ? error.message : "Synthesis failed"; - await store.updateNode(synthNode.id, { - status: "failed", - error: message, - completedAt: new Date().toISOString(), - }); - throw error; - } -} - -// ------------------------------------------------------------------- -// Revision synthesis — targeted revision from reviewer feedback -// ------------------------------------------------------------------- - -/** - * Build a prompt for targeted revision of an existing ClaimMap based on - * reviewer feedback (revision request). - */ -export function buildRevisionPrompt( - existingClaimMap: ClaimMap, - revisionRequest: ReviewRevisionRequest, -): string { - const claimMapJson = JSON.stringify(existingClaimMap, null, 2); - const truncatedMap = claimMapJson.length > 4000 - ? claimMapJson.slice(0, 4000) + "\n... (truncated)" - : claimMapJson; - - const revisionPointsStr = revisionRequest.revisionPoints.map((rp, i) => - `${i + 1}. **${rp.target}** ${rp.issueId ? `[${rp.issueId}]` : ""} - - Problem: ${rp.problem} - - Expected outcome: ${rp.expectedOutcome}` - ).join("\n"); - - const antiPatternStr = revisionRequest.antiPatternsToFix.length > 0 - ? `\n## Anti-Patterns to Fix\n${revisionRequest.antiPatternsToFix.map(ap => - `- **${ap.pattern}** at ${ap.location}: ${ap.description}\n Fix: ${ap.suggestedFix}` - ).join("\n")}` - : ""; - - return `You are operating as the Results and Evidence Analyst and performing a TARGETED REVISION of an existing ClaimMap. - -## CONTEXT -The review assessment has identified specific issues that must be fixed. -You must revise the ClaimMap to address EACH revision point below. - -## STRICT RULES -1. Address EVERY revision point — do not skip any -2. Do NOT fabricate new evidence that wasn't in the original sources -3. You MAY: re-classify claim strength, add caveats, remove unsupported claims, fix contradictions, update gap analysis -4. You MAY NOT: invent new sources, hallucinate citations, add claims without evidence -5. Preserve claims that were NOT flagged — only modify what reviewers identified - -## Existing ClaimMap -${truncatedMap} - -## Revision Points (from reviewer round ${revisionRequest.fromRound}) -${revisionPointsStr} -${antiPatternStr} - -## Output Format -Respond with valid JSON matching the ClaimMap schema — the COMPLETE revised ClaimMap (not just changes). -Include ALL claims (modified and unmodified).`; -} - -/** - * Execute a targeted revision of an existing ClaimMap based on reviewer feedback. - */ -export async function executeRevisionSynthesis( - session: DeepResearchSession, - existingClaimMap: ClaimMap, - revisionRequest: ReviewRevisionRequest, - abortSignal?: AbortSignal, -): Promise<{ - claimMap: ClaimMap; - artifacts: DeepResearchArtifact[]; -}> { - const { model } = getModelForRole("results_and_evidence_analyst", session.config); - const budgetCheck = checkBudget("results_and_evidence_analyst", session.budget, session.config.budget); - if (!budgetCheck.allowed) { - throw new Error(`Results and Evidence Analyst budget exceeded: ${budgetCheck.reason}`); - } - - const synthNode = await store.createNode(session.id, { - nodeType: "synthesize_claims", - label: `Results and Evidence Analyst: Revise claim map (addressing ${revisionRequest.revisionPoints.length} critique points)`, - assignedRole: "results_and_evidence_analyst", - input: { - revisionFromRound: revisionRequest.fromRound, - issueCount: revisionRequest.issueIds.length, - revisionPointCount: revisionRequest.revisionPoints.length, - antiPatternCount: revisionRequest.antiPatternsToFix.length, - }, - contextTag: "planning", - }); - - await store.updateNode(synthNode.id, { - status: "running", - startedAt: new Date().toISOString(), - }); - - try { - const prompt = buildRevisionPrompt(existingClaimMap, revisionRequest); - - const result = await generateText({ - model, - system: "You are operating as the Results and Evidence Analyst and revising an existing ClaimMap based on critique feedback. Respond ONLY with valid JSON.", - messages: [{ role: "user", content: prompt }], - abortSignal, - }); - - const tokens = result.usage?.totalTokens ?? 0; - const budget = trackUsage(session.budget, "results_and_evidence_analyst", synthNode.id, tokens); - await store.updateSession(session.id, { budget }); - - const claimMap = parseClaimMap(result.text); - - await store.updateNode(synthNode.id, { - status: "completed", - output: claimMap as unknown as Record<string, unknown>, - completedAt: new Date().toISOString(), - }); - - const provenance: ArtifactProvenance = { - sourceNodeId: synthNode.id, - sourceArtifactIds: [revisionRequest.targetClaimMapId], - model: "results_and_evidence_analyst", - generatedAt: new Date().toISOString(), - }; - - const artifact = await store.createArtifact( - session.id, - synthNode.id, - "claim_map", - `Revised Claim Map (${claimMap.claims.length} claims, revision round ${revisionRequest.fromRound})`, - claimMap as unknown as Record<string, unknown>, - provenance, - ); - - await store.appendEvent(session.id, "synthesis_completed", synthNode.id, "results_and_evidence_analyst", undefined, undefined, { - revision: true, - fromRound: revisionRequest.fromRound, - claimsCount: claimMap.claims.length, - issuesAddressed: revisionRequest.issueIds.length, - }); - - return { claimMap, artifacts: [artifact] }; - } catch (error) { - const message = error instanceof Error ? error.message : "Revision synthesis failed"; - await store.updateNode(synthNode.id, { - status: "failed", - error: message, - completedAt: new Date().toISOString(), - }); - throw error; - } -} - -// ------------------------------------------------------------------- -// Helpers -// ------------------------------------------------------------------- - -function parseClaimMap(text: string): ClaimMap { - // Try JSON fence first - const fenceMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/); - const jsonStr = fenceMatch ? fenceMatch[1].trim() : text.trim(); - - try { - const parsed = JSON.parse(jsonStr); - return validateClaimMap(parsed); - } catch { - // Try to find JSON object in text - const firstBrace = text.indexOf("{"); - if (firstBrace >= 0) { - let depth = 0; - let inString = false; - let escape = false; - for (let i = firstBrace; i < text.length; i++) { - const ch = text[i]; - if (escape) { escape = false; continue; } - if (ch === "\\") { escape = true; continue; } - if (ch === '"') { inString = !inString; continue; } - if (inString) continue; - if (ch === "{") depth++; - if (ch === "}") { - depth--; - if (depth === 0) { - const parsed = JSON.parse(text.slice(firstBrace, i + 1)); - return validateClaimMap(parsed); - } - } - } - } - throw new Error("Failed to parse ClaimMap from Results and Evidence Analyst output"); - } -} - -function validateClaimMap(obj: Record<string, unknown>): ClaimMap { - return { - claims: Array.isArray(obj.claims) ? obj.claims : [], - supportMatrix: (obj.supportMatrix as Record<string, number[]>) ?? {}, - contradictions: Array.isArray(obj.contradictions) ? obj.contradictions : [], - gaps: Array.isArray(obj.gaps) ? obj.gaps : [], - confidenceDistribution: (obj.confidenceDistribution as Record<string, number>) ?? { - strong: 0, - moderate: 0, - weak: 0, - unsupported: 0, - }, - }; -} diff --git a/src/lib/deep-research/types.ts b/src/lib/deep-research/types.ts index 4a94676f..a27fac20 100644 --- a/src/lib/deep-research/types.ts +++ b/src/lib/deep-research/types.ts @@ -1,2017 +1,9 @@ // ============================================================= -// Deep Research — Type Definitions +// Deep Research — Aggregated type surface // ============================================================= -// --- Enums as union types --- - -/** Valid transitions: - * intake → running → paused | awaiting_approval | awaiting_user_confirmation | reviewing | awaiting_resource | completed | failed - * paused → running - * awaiting_approval → running - * awaiting_user_confirmation → running | cancelled - * reviewing → running | awaiting_user_confirmation - * awaiting_resource → running | failed - * failed → running (retry) - */ -export type SessionStatus = - | "intake" - | "planning" - | "running" - | "paused" - | "awaiting_approval" - | "awaiting_user_confirmation" - | "awaiting_resource" - | "reviewing" - | "planning_in_progress" - | "literature_in_progress" - | "literature_blocked" - | "awaiting_additional_literature" - | "validation_planning_in_progress" - | "execution_prepared" - | "execution_in_progress" - | "final_report_generated" - | "completed" - | "stopped_by_user" - | "failed" - | "cancelled"; - -export type ContextTag = - | "intake" - | "planning" - | "final_report"; - -export const VALID_CONTEXT_TAGS: readonly ContextTag[] = [ - "intake", - "planning", - "final_report", -]; - -export type NodeType = - | "intake" - | "plan" - | "evidence_gather" - | "evidence_extract" - | "summarize" - | "synthesize" - | "review" - | "audit" - | "validation_plan" - | "resource_request" - | "execute" - | "monitor" - | "result_collect" - | "result_compare" - | "approve" - | "final_report" - | "retrieve" - | "synthesize_claims" - | "data_download" - | "preprocess" - | "skill_route"; - -export type NodeStatus = - | "pending" - | "queued" - | "running" - | "completed" - | "failed" - | "skipped" - | "awaiting_approval" - | "awaiting_user_confirmation" - | "superseded"; - -export type ModelRole = - | "main_brain" - | "researcher" - | "literature_intelligence_analyst" - | "experiment_architecture_designer" - | "research_software_engineer" - | "experiment_operations_engineer" - | "results_and_evidence_analyst" - | "research_asset_reuse_specialist" - | "worker" - | "synthesizer"; - -export type StructuredRoleCategory = "main_brain" | "meta_worker"; - -export type StructuredPromptKind = - | "system" - | "task_intake" - | "progress_update" - | "handoff" - | "escalation" - | "completion"; - -export type StructuredSkillKind = - | "literature_analysis" - | "experiment_design" - | "code_implementation" - | "experiment_execution" - | "result_analysis" - | "artifact_packaging" - | "coordination"; - -export interface StructuredRolePrompt { - kind: StructuredPromptKind; - title: string; - objective: string; - requiredSections: string[]; - constraints: string[]; -} - -export interface StructuredRoleSkill { - id: string; - kind: StructuredSkillKind; - name: string; - purpose: string; - inputs: string[]; - outputs: string[]; - qualityChecks: string[]; -} - -export interface StructuredRoleCollaboration { - partnerRoleId: ModelRole; - collaborationType: "delegation" | "handoff" | "review" | "feedback" | "escalation" | "reuse"; - trigger: string; - payload: string[]; - expectedResponse: string[]; -} - -export interface StructuredRoleDefinition { - roleId: ModelRole; - category: StructuredRoleCategory; - roleName: string; - workflowSegment: string; - defaultNodeType: NodeType; - defaultContextTag: ContextTag; - summaryArtifactType: ArtifactType; - corePositioning: string; - coreResponsibilities: string[]; - skillRequirements: string[]; - collaborationRequirements: string[]; - performanceStandards: string[]; - prompts: StructuredRolePrompt[]; - skills: StructuredRoleSkill[]; - collaborations: StructuredRoleCollaboration[]; -} - -export interface StructuredCommunicationProtocol { - id: string; - fromRoleId: ModelRole; - toRoleId: ModelRole; - goal: string; - trigger: string; - requiredPayload: string[]; - responseContract: string[]; - escalationPath: string; -} - -export interface StructuredTaskAssignment { - roleId: ModelRole; - roleName: string; - workflowSegment: string; - objective: string; - deliverables: string[]; - dependencies: ModelRole[]; - status: "planned" | "in_progress" | "blocked" | "completed"; -} - -export interface StructuredTaskBoard { - objective: string; - coordinatorRoleId: ModelRole; - assignments: StructuredTaskAssignment[]; - milestones: string[]; - completionCriteria: string[]; -} - -export interface StructuredHandoffPacket { - type: "handoff" | "progress_update" | "escalation"; - fromRoleId: ModelRole; - toRoleId: ModelRole; - goal: string; - payload: string[]; - expectedResponse: string[]; - status: "drafted" | "shared" | "acknowledged"; -} - -export type ArtifactType = - | "research_brief" - | "task_graph" - | "evidence_card" - | "literature_round_summary" - | "structured_summary" - | "reviewer_packet" - | "review_assessment" - | "main_brain_audit" - | "provisional_conclusion" - | "validation_plan" - | "execution_manifest" - | "execution_plan" - | "step_result" - | "experiment_result" - | "validation_report" - | "final_report" - | "checkpoint" - | "evidence_card_collection" - | "claim_map" - | "memory_profile" - | "memory_snapshot" - | "memory_index" - | "data_manifest"; - -export type EventType = - | "session_created" - | "node_created" - | "node_started" - | "node_completed" - | "node_failed" - | "artifact_created" - | "user_message" - | "brain_response" - | "approval_requested" - | "approval_granted" - | "approval_denied" - | "session_completed" - | "session_failed" - | "checkpoint_created" - | "confirmation_requested" - | "user_confirmed" - | "user_requested_revision" - | "user_requested_branch" - | "user_rejected_result" - | "user_requested_stop" - | "user_approved_execution" - | "user_approved_remote_submission" - | "literature_round_started" - | "literature_round_completed" - | "review_started" - | "review_completed" - | "execution_submitted" - | "execution_completed" - | "resource_requested" - | "resource_acquired" - | "requirement_changed" - | "nodes_superseded" - | "consistency_check" - | "skill_routing_completed" - | "synthesis_completed" - | "execution_plan_created" - | "data_download_completed"; - -export type MessageRole = "user" | "main_brain" | "system"; - -/** How the user responded to a confirmation gate. */ -export type ConfirmationOutcome = - | "confirmed" - | "revision_requested" - | "branch_requested" - | "rejected" - | "stopped"; - -// --- Core Interfaces --- - -export interface DeepResearchSession { - id: string; - workspaceId: string; - title: string; - status: SessionStatus; - contextTag: ContextTag; - config: DeepResearchConfig; - budget: BudgetUsage; - /** ID of the latest checkpoint artifact when status is awaiting_user_confirmation. */ - pendingCheckpointId: string | null; - /** Current literature round number (0 = not started). */ - literatureRound: number; - /** Current review round number (0 = not started). */ - reviewerRound: number; - /** Current execution loop number (0 = not started). */ - executionLoop: number; - error: string | null; - /** ID of the bound remote execution profile (from research-exec module). */ - remoteProfileId: string | null; - createdAt: string; - updatedAt: string; -} - -export interface DeepResearchMessage { - id: string; - sessionId: string; - role: MessageRole; - content: string; - metadata: Record<string, unknown> | null; - /** Which node produced or relates to this message. */ - relatedNodeId: string | null; - /** Which artifacts this message references. */ - relatedArtifactIds: string[]; - createdAt: string; -} - -export interface DeepResearchNode { - id: string; - sessionId: string; - parentId: string | null; - nodeType: NodeType; - label: string; - status: NodeStatus; - assignedRole: ModelRole; - assignedModel: string | null; - input: Record<string, unknown> | null; - output: Record<string, unknown> | null; - error: string | null; - dependsOn: string[]; - supersedesId: string | null; - supersededById: string | null; - branchKey: string | null; - retryOfId: string | null; - retryCount: number; - /** Which context tag spawned this node. */ - contextTag: ContextTag; - /** Legacy compatibility field; workflow routing no longer depends on stage numbering. */ - stageNumber: number; - /** Whether this node requires user confirmation after completion. */ - requiresConfirmation: boolean; - confirmedAt: string | null; - confirmedBy: string | null; - confirmationOutcome: ConfirmationOutcome | null; - positionX: number | null; - positionY: number | null; - startedAt: string | null; - completedAt: string | null; - createdAt: string; - updatedAt: string; -} - -export interface DeepResearchArtifact { - id: string; - sessionId: string; - nodeId: string | null; - artifactType: ArtifactType; - title: string; - content: Record<string, unknown>; - provenance: ArtifactProvenance | null; - version: number; - createdAt: string; -} - -export interface DeepResearchEvent { - id: string; - sessionId: string; - eventType: EventType; - nodeId: string | null; - actorType: string | null; - actorId: string | null; - model: string | null; - payload: Record<string, unknown> | null; - createdAt: string; -} - -// --- Configuration --- - -export interface DeepResearchConfig { - modelOverrides?: Partial<Record<ModelRole, { provider: string; modelId: string }>>; - /** The model resolved from settings at session creation time. */ - resolvedModel?: { provider: string; modelId: string }; - /** Keep the UI shell but disable the current orchestration/runtime. */ - interfaceOnly?: boolean; - budget: BudgetLimits; - /** Max number of worker nodes created per fan-out. */ - maxWorkerFanOut: number; - /** Max review rounds before forcing advancement. */ - maxReviewerRounds: number; - /** Max execution retry loops before forcing final report. */ - maxExecutionLoops: number; - /** Max concurrent worker node executions. */ - maxWorkerConcurrency: number; - /** Literature collection controls. */ - literature: LiteratureConfig; - /** Execution controls. */ - execution: ExecutionConfig; - /** Optional: enable dynamic skill routing. */ - skillRouting?: { enabled: boolean }; -} - -export interface LiteratureConfig { - /** Max number of literature collection rounds (including reviewer-requested). */ - maxLiteratureRounds: number; - /** Max papers per single literature round. */ - maxPapersPerRound: number; - /** Max total papers across all rounds. */ - maxTotalPapers: number; - /** Max rounds triggered by reviewer requests for more literature. */ - maxReviewerRequestedExpansionRounds: number; - /** Max retries for failed searches within a single round. */ - maxSearchRetries: number; -} - -export interface ExecutionConfig { - /** Default launcher type for execution. */ - defaultLauncherType: LauncherType; - /** Default resource profiles for rlaunch/rjob. */ - defaultResources: ResourceProfile; - /** Default mounts for rlaunch/rjob. */ - defaultMounts: MountSpec[]; - /** Default charged group for resource allocation. */ - defaultChargedGroup: string; -} - -// --- Budget --- - -export interface BudgetLimits { - maxTotalTokens: number; - maxOpusTokens: number; -} - -export interface BudgetUsage { - totalTokens: number; - opusTokens: number; - byRole: Partial<Record<ModelRole, number>>; - byNode: Record<string, number>; -} - -// --- Artifact & Review --- - -export interface ArtifactProvenance { - sourceNodeId: string; - sourceArtifactIds: string[]; - model: string; - generatedAt: string; -} - -export interface ReviewerPacket { - reviewerRole: "results_and_evidence_analyst"; - verdict: "approve" | "revise" | "reject"; - critique: string; - suggestions: string[]; - confidence: number; - /** Specific gaps the reviewer identifies that may need more literature. */ - identifiedGaps?: string[]; - /** Whether this reviewer thinks experimental validation is needed. */ - needsExperimentalValidation?: boolean; - /** Specific experiments the reviewer suggests. */ - suggestedExperiments?: string[]; -} - -/** Final review assessment from the Results and Evidence Analyst. */ -export interface ReviewAssessment { - reviewerRole?: "results_and_evidence_analyst"; - reviewerSummary?: string; - reviewHighlights?: string[]; - openIssues?: string[]; - reviewRounds?: number; - combinedVerdict: "approve" | "revise" | "reject"; - combinedConfidence: number; - /** What would reduce uncertainty. */ - uncertaintyReducers: string[]; - /** Whether reviewers recommend more literature. */ - needsMoreLiterature: boolean; - /** Specific literature gaps identified. */ - literatureGaps: string[]; - /** Whether reviewers recommend experimental validation. */ - needsExperimentalValidation: boolean; - /** Suggested experiments from reviewers. */ - suggestedExperiments: string[]; -} - -/** Main brain's audit/opinion on a stage result, shown at every checkpoint. */ -export interface MainBrainAudit { - /** What was completed in this stage. */ - whatWasCompleted: string; - /** Whether the main brain thinks the result is correct/good. */ - resultAssessment: "good" | "acceptable" | "concerning" | "problematic"; - /** Specific issues or risks the main brain sees. */ - issuesAndRisks: string[]; - /** What the main brain recommends as the next action. */ - recommendedNextAction: string; - /** What "Continue" will do if the user clicks it. */ - continueWillDo: string; - /** Alternative actions the user could take. */ - alternativeActions: AlternativeAction[]; - /** Whether the main brain has sufficient confidence to proceed. */ - canProceed: boolean; -} - -export interface AlternativeAction { - label: string; - description: string; - /** Maps to a ConfirmationOutcome or custom action. */ - actionType: "continue" | "revise" | "retry" | "more_literature" | "fix_code" | "change_params" | "more_resources" | "stop"; -} - -// --- Literature Rounds --- - -export interface LiteratureRoundState { - roundId: string; - roundNumber: number; - targetQuestion: string; - subQuestions: string[]; - maxPapers: number; - currentPaperCount: number; - status: "pending" | "running" | "completed" | "failed"; - completionReason: string | null; - /** Who/what requested this round. */ - requestedBy: "main_brain" | "reviewer_request" | "user_request"; - /** Summary of what this round covered. */ - coverageSummary: string | null; - /** Evidence node IDs created for this round. */ - nodeIds: string[]; - createdAt: string; -} - -// --- Execution / Resource Acquisition --- - -export type LauncherType = "rlaunch" | "rjob" | "slurm" | "local_shell" | "ssh"; - -export interface MountSpec { - source: string; - target: string; -} - -export interface ResourceProfile { - gpu: number; - memoryMb: number; - cpu: number; - privateMachine: "yes" | "no" | "group"; - maxWaitDuration?: string; -} - -/** Structured rlaunch request. */ -export interface RLaunchManifest { - launcherType: "rlaunch"; - gpu: number; - memoryMb: number; - cpu: number; - chargedGroup: string; - privateMachine: "yes" | "no" | "group"; - mounts: MountSpec[]; - maxWaitDuration: string; - command: string; - /** Human-readable description of what this request is for. */ - purpose: string; -} - -/** Structured rjob submission request. */ -export interface RJobManifest { - launcherType: "rjob"; - jobName: string; - gpu: number; - memoryMb: number; - cpu: number; - chargedGroup: string; - privateMachine: "yes" | "no" | "group"; - mounts: MountSpec[]; - image: string; - command: string; - commandArgs: string[]; - env?: Record<string, string>; - priority?: number; - hostNetwork?: boolean; - /** Human-readable description of what this job will do. */ - purpose: string; -} - -export type ExecutionManifest = RLaunchManifest | RJobManifest | SlurmManifest; - -/** Tracks the lifecycle of one execution/job submission. */ -export interface ExecutionRecord { - id: string; - sessionId: string; - nodeId: string; - manifest: ExecutionManifest; - status: "pending" | "submitted" | "running" | "completed" | "failed" | "cancelled"; - /** Remote job ID if applicable. */ - remoteJobId: string | null; - /** Sanitized command shown to user. */ - sanitizedCommand: string; - /** Output/result bundle. */ - resultBundle: Record<string, unknown> | null; - submittedAt: string | null; - completedAt: string | null; - createdAt: string; -} - -/** Validation plan that ties literature evidence back to experimental validation. */ -export interface ValidationPlan { - objective: string; - hypothesis: string; - /** What the literature and reviewer debate suggests should happen. */ - literaturePrediction: string; - /** Required resources. */ - requiredResources: ResourceProfile; - /** Datasets needed. */ - datasets: string[]; - /** Scripts/commands to run. */ - steps: ValidationStep[]; - /** What outputs are expected. */ - expectedOutputs: string[]; - /** How to determine failure. */ - failureCriteria: string[]; - /** How to determine success. */ - successCriteria: string[]; -} - -export interface ValidationStep { - stepNumber: number; - description: string; - command?: string; - scriptPath?: string; - launcherType?: LauncherType; - requiresApproval: boolean; - expectedDuration?: string; -} - -// --- Brain Decisions --- - -export interface BrainDecision { - action: "advance_context" | "revise_plan" | "request_approval" | "complete" | "respond_to_user"; - nextContextTag?: ContextTag; - nodesToCreate?: NodeCreationSpec[]; - messageToUser?: string; - reasoning?: string; -} - -export type CheckpointInteractionMode = "confirmation" | "answer_required"; - -export interface NodeCreationSpec { - nodeType: NodeType; - label: string; - assignedRole: ModelRole; - input?: Record<string, unknown>; - dependsOn?: string[]; - parentId?: string; - branchKey?: string; - contextTag?: ContextTag; -} - -// --- Checkpoint Package --- - -export interface CheckpointPackage { - checkpointId: string; - sessionId: string; - nodeId: string; - stepType: string; - contextTag: ContextTag; - title: string; - humanSummary: string; - machineSummary: string; - /** Main brain's audit/opinion on this stage result. */ - mainBrainAudit: MainBrainAudit; - artifactsToReview: string[]; - currentFindings: string; - openQuestions: string[]; - recommendedNextAction: string; - recommendedWorker?: { - roleId: ModelRole; - roleName: string; - nodeType: NodeType; - label: string; - }; - promptUsed?: { - title: string; - kind: StructuredPromptKind; - objective: string; - }; - /** What clicking "Continue" will actually do. */ - continueWillDo: string; - alternativeNextActions: string[]; - requiresUserConfirmation: boolean; - interactionMode?: CheckpointInteractionMode; - isFinalStep?: boolean; - /** Computed transition action from TransitionResolver. */ - transitionAction?: TransitionAction; - /** Literature round info if relevant. */ - literatureRoundInfo?: { - roundNumber: number; - papersCollected: number; - retrievalTaskCount: number; - successfulTaskCount: number; - failedTaskCount: number; - emptyTaskCount: number; - coverageSummary: string; - }; - /** Review assessment info if relevant. */ - reviewInfo?: ReviewAssessment; - /** Validation/execution info if relevant. */ - executionInfo?: { - stepsCompleted: number; - stepsTotal: number; - currentStatus: string; - }; - createdAt: string; -} - -/** What the main brain decides after reading user confirmation feedback. */ -export type ConfirmationAction = - | "continue" - | "revise" - | "retry" - | "branch" - | "supersede" - | "stop"; - -export interface ConfirmationDecision { - action: ConfirmationAction; - reasoning: string; - nodesToCreate?: NodeCreationSpec[]; - nextContextTag?: ContextTag; - messageToUser?: string; -} - -// --- Default Config --- - -export const DEFAULT_LITERATURE_CONFIG: LiteratureConfig = { - maxLiteratureRounds: 3, - maxPapersPerRound: 10, - maxTotalPapers: 30, - maxReviewerRequestedExpansionRounds: 1, - maxSearchRetries: 2, -}; - -export const DEFAULT_EXECUTION_CONFIG: ExecutionConfig = { - defaultLauncherType: "rjob", - defaultResources: { - gpu: 2, - memoryMb: 200000, - cpu: 32, - privateMachine: "yes", - }, - defaultMounts: [ - { source: "gpfs://gpfs1/suencheng", target: "/mnt/shared-storage-user/suencheng" }, - { source: "gpfs://gpfs1/ai4sreason", target: "/mnt/shared-storage-user/ai4sreason" }, - ], - defaultChargedGroup: "ai4sdata_gpu", -}; - -export const DEFAULT_CONFIG: DeepResearchConfig = { - interfaceOnly: false, - budget: { - maxTotalTokens: 2_000_000, - maxOpusTokens: 500_000, - }, - maxWorkerFanOut: 1, - maxReviewerRounds: 2, - maxExecutionLoops: 3, - maxWorkerConcurrency: 1, - literature: DEFAULT_LITERATURE_CONFIG, - execution: DEFAULT_EXECUTION_CONFIG, -}; - -export function createEmptyUsage(): BudgetUsage { - return { totalTokens: 0, opusTokens: 0, byRole: {}, byNode: {} }; -} - -// ============================================================= -// RequirementState & ConstraintState -// ============================================================= - -export type RequirementStatus = "active" | "satisfied" | "dropped"; -export type ConstraintType = "budget" | "time" | "scope" | "method" | "resource"; -export type ConstraintStatus = "active" | "relaxed" | "violated"; - -export interface Requirement { - id: string; - text: string; - source: string; - priority: "critical" | "high" | "medium" | "low"; - status: RequirementStatus; - satisfiedByNodeIds: string[]; - addedAtContextTag: ContextTag; -} - -export interface Constraint { - id: string; - text: string; - type: ConstraintType; - value: string; - status: ConstraintStatus; - addedAtContextTag: ContextTag; -} - -export interface RequirementState { - requirements: Requirement[]; - constraints: Constraint[]; - version: number; - lastModifiedAt: string; - lastModifiedBy: string; - /** Original user goal text (never changes). */ - originalUserGoal: string; - /** Currently approved goal (may differ from original after user feedback). */ - currentApprovedGoal: string; - /** Latest user instruction/feedback text. */ - latestUserInstruction: string | null; - /** Approved research scope description. */ - approvedResearchScope: string | null; - /** Approved experiment scope description. */ - approvedExperimentScope: string | null; - /** Whether execution is explicitly allowed. */ - executionAllowed: boolean; - /** Main brain's latest accepted interpretation of user goal. */ - latestMainBrainAcceptedInterpretation: string | null; - /** Version this state supersedes. */ - supersedesVersion: number | null; -} - -export interface RequirementDiff { - added: Requirement[]; - removed: Requirement[]; - modified: Array<{ id: string; field: string; oldValue: unknown; newValue: unknown }>; - constraintsChanged: boolean; -} - -// ============================================================= -// TransitionAction -// ============================================================= - -export interface TransitionAction { - nextContextTag: ContextTag; - nodesToCreate: NodeCreationSpec[]; - nodesToSupersede: string[]; - description: string; -} - -// ============================================================= -// Review History -// ============================================================= - -export interface ReviewRound { - round: number; - reviewerPacket: ReviewerPacket; -} - -// Extend review assessment results with optional history fields. -export interface ReviewAssessmentExtended extends ReviewAssessment { - rounds: ReviewRound[]; - reviewHistory?: ReviewRound[]; -} - -// ============================================================= -// Execution Records -// ============================================================= - -export type ExecutionRecordType = "rlaunch" | "rjob" | "local"; -export type ExecutionRecordStatus = "pending" | "submitted" | "running" | "completed" | "failed" | "cancelled"; - -export interface PersistedExecutionRecord { - id: string; - sessionId: string; - nodeId: string; - recordType: ExecutionRecordType; - status: ExecutionRecordStatus; - remoteJobId: string | null; - remoteHost: string | null; - command: string; - configJson: Record<string, unknown>; - resultJson: Record<string, unknown> | null; - submittedAt: string | null; - startedAt: string | null; - completedAt: string | null; - createdAt: string; -} - -// ============================================================= -// DAG Validation -// ============================================================= - -export type DAGErrorType = "cycle" | "orphan" | "dangling" | "duplicate"; - -export interface DAGError { - type: DAGErrorType; - nodeIds: string[]; - message: string; -} - -export interface DAGValidationResult { - valid: boolean; - errors: DAGError[]; -} - -// ============================================================= -// Consistency Check -// ============================================================= - -export interface ConsistencyReport { - valid: boolean; - warnings: string[]; - errors: string[]; -} - -// ============================================================= -// Actor Runtime Types -// ============================================================= - -export interface ActorExecutionContext { - session: DeepResearchSession; - messages: DeepResearchMessage[]; - allNodes: DeepResearchNode[]; - allArtifacts: DeepResearchArtifact[]; - skillCatalog?: Array<{ slug: string; name: string; description?: string | null }>; - skillTools?: Record<string, unknown>; -} - -export interface ActorArtifactDraft { - artifactType: ArtifactType; - title: string; - content: Record<string, unknown>; - provenance?: ArtifactProvenance | null; -} - -export interface ActorExecutionResult { - output: Record<string, unknown>; - artifacts: DeepResearchArtifact[]; - tokensUsed: number; -} - -// ============================================================= -// Language State -// ============================================================= - -export interface LanguageState { - /** Detected language of latest user message (e.g., "zh", "en", "ja"). */ - currentUserLanguage: string; - /** Preferred output language for user-facing content. */ - preferredOutputLanguage: string; - /** Last detected language before any override. */ - lastDetectedUserLanguage: string; - /** When the language state was last updated. */ - lastLanguageUpdateAt: string; -} - -// ============================================================= -// Evidence Sufficiency -// ============================================================= - -export type EvidenceRetrievalStatus = - | "success" - | "partial" - | "failed_retrieval" - | "insufficient_evidence" - | "empty"; - -export interface EvidenceSufficiencyReport { - /** Overall sufficiency assessment. */ - sufficient: boolean; - /** Per-stream status. */ - streams: Array<{ - nodeId: string; - label: string; - status: EvidenceRetrievalStatus; - sourcesFound: number; - failureReason?: string; - }>; - /** Total unique sources across all streams. */ - totalSources: number; - /** Streams that failed or returned empty. */ - failedStreams: number; - /** Whether synthesis should proceed (requires at least some evidence). */ - canSynthesize: boolean; - /** Missing topics that need re-retrieval. */ - missingTopics: string[]; -} - -// ============================================================= -// Evidence Cards — Structured evidence format -// ============================================================= - -export interface RawExcerpt { - text: string; - sourceIndex: number; - page?: string; - section?: string; -} - -export interface SourceEntry { - title: string; - url: string; - authors?: string[]; - year?: number; - venue?: string; - doi?: string; - retrievalMethod: string; - retrievedAt: string; -} - -export interface EvidenceCard { - id: string; - query: string; - sources: SourceEntry[]; - rawExcerpts: RawExcerpt[]; - retrievalStatus: EvidenceRetrievalStatus; - /** Number of sources successfully retrieved. */ - sourcesFound: number; - /** Total sources attempted. */ - sourcesAttempted: number; - /** Free-text notes about retrieval quality. */ - retrievalNotes: string; - createdAt: string; -} - -export interface EvidenceCardCollection { - cards: EvidenceCard[]; - totalSources: number; - totalExcerpts: number; - retrievalSummary: { - successful: number; - partial: number; - failed: number; - empty: number; - }; -} - -// ============================================================= -// Claim Map — Synthesizer output -// ============================================================= - -export type ClaimStrength = "strong" | "moderate" | "weak" | "unsupported"; - -export interface Claim { - id: string; - text: string; - strength: ClaimStrength; - supportingSources: number[]; - contradictingSources: number[]; - category: string; - /** Distinguish what kind of knowledge this claim is. */ - knowledgeType: "retrieved_evidence" | "background_knowledge" | "assumption" | "speculation"; -} - -export interface Contradiction { - claimAId: string; - claimBId: string; - description: string; - possibleResolution: string; -} - -export interface GapAnalysis { - topic: string; - description: string; - suggestedQueries: string[]; - priority: "high" | "medium" | "low"; -} - -export interface ClaimMap { - claims: Claim[]; - supportMatrix: Record<string, number[]>; - contradictions: Contradiction[]; - gaps: GapAnalysis[]; - confidenceDistribution: Record<ClaimStrength, number>; -} - -// ============================================================= -// Memory Fabric — Long-running research memory -// ============================================================= - -export type ResearchMemoryKind = "semantic" | "episodic" | "procedural"; - -export type ResearchMemoryStatus = "active" | "superseded" | "archived"; - -export type ResearchMemoryCategory = - | "user_goal" - | "constraint" - | "evidence" - | "claim" - | "gap" - | "decision" - | "execution" - | "workflow"; - -export interface ResearchMemoryAnchor { - artifactId?: string; - artifactType?: ArtifactType; - nodeId?: string; - messageId?: string; - sourceIndex?: number; - excerptIndex?: number; - claimId?: string; - gapIndex?: number; - field?: string; - note?: string; -} - -export interface ResearchMemoryItem { - id: string; - kind: ResearchMemoryKind; - category: ResearchMemoryCategory; - title: string; - summary: string; - details?: string; - tags: string[]; - keywords: string[]; - importance: number; - confidence: number; - status: ResearchMemoryStatus; - createdAt: string; - updatedAt: string; - provenance: { - sourceType: "artifact" | "message" | "event" | "derived"; - artifactId?: string; - nodeId?: string; - eventId?: string; - messageId?: string; - }; - /** Exact back-pointers into the source-of-truth records for research traceability. */ - anchors?: ResearchMemoryAnchor[]; - relatedMemoryIds?: string[]; -} - -export interface ResearchMemoryProfile { - sessionId: string; - generatedAt: string; - objective: string; - currentPhase: ContextTag; - latestCheckpointTitle?: string; - latestRecommendedNextAction?: string; - activeRequirements: string[]; - activeConstraints: string[]; - openQuestions: string[]; - activeHypotheses: string[]; - latestPlanSummary?: string; - keyDecisions: string[]; -} - -export interface ResearchMemorySnapshot { - sessionId: string; - generatedAt: string; - title: string; - summary: string; - acceptedFacts: string[]; - contestedFacts: string[]; - unresolvedGaps: string[]; - nextStep: string; - focusAreas: string[]; - relatedArtifactIds: string[]; -} - -export interface ResearchMemoryIndex { - sessionId: string; - generatedAt: string; - itemCount: number; - /** Derived retrieval cache built from artifacts/messages, not the source of truth itself. */ - sourceOfTruth: "artifacts_and_messages"; - items: ResearchMemoryItem[]; - stats: { - semanticCount: number; - episodicCount: number; - proceduralCount: number; - activeCount: number; - }; -} - -export interface ResearchMemoryRetrievalResult { - profile: ResearchMemoryProfile; - snapshot: ResearchMemorySnapshot | null; - items: Array<ResearchMemoryItem & { retrievalScore: number }>; - query: string; -} - -// ============================================================= -// Synthesizer-Facing Revision Request -// ============================================================= - -export interface ReviewPatternFlag { - pattern: string; - location: string; - description: string; - severity: "critical" | "major" | "minor"; - suggestedFix: string; -} - -export interface ReviewRevisionRequest { - /** ID of the review round that produced this request. */ - fromRound: number; - /** Issue IDs that need addressing. */ - issueIds: string[]; - /** Point-by-point revision instructions for the synthesizer. */ - revisionPoints: RevisionPoint[]; - /** The ClaimMap artifact ID being revised. */ - targetClaimMapId: string; - /** Anti-patterns to fix. */ - antiPatternsToFix: ReviewPatternFlag[]; -} - -export interface RevisionPoint { - /** Which claim or section to revise. */ - target: string; - /** What's wrong. */ - problem: string; - /** What the reviewer expects after revision. */ - expectedOutcome: string; - /** Linked issue ID. */ - issueId?: string; -} - -// ============================================================= -// Execution Planner — Multi-stage pipeline -// ============================================================= - -export interface DataRequirement { - name: string; - source: string; - format: string; - estimatedSizeGb: number; - cachePath?: string; -} - -export interface ExecutionStage { - stageNumber: number; - name: string; - description: string; - nodeType: NodeType; - dependencies: number[]; - estimatedGPUHours: number; - dataRequirements: DataRequirement[]; - commands: string[]; - expectedOutputs: string[]; -} - -export interface ExecutionPlanFull { - stages: ExecutionStage[]; - totalEstimatedGPUHours: number; - dataRequirements: DataRequirement[]; - prerequisites: string[]; -} - -export interface SlurmManifest { - launcherType: "slurm"; - partition: string; - account: string; - nodes: number; - gpusPerNode: number; - time: string; - modules: string[]; - command: string; - jobName?: string; - outputPath?: string; - errorPath?: string; -} - -// ============================================================= -// Skill Library — Dynamic skill/task registry -// ============================================================= - -export type SkillCategory = "retrieval" | "synthesis" | "review" | "execution" | "report"; - -export interface SkillDefinition { - id: string; - name: string; - description: string; - category: SkillCategory; - nodeType: NodeType; - defaultRole: ModelRole; - estimatedTokens: number; -} - -export interface SkillRoutingDecision { - selectedSkills: string[]; - reasoning: string; - nodeSpecs: NodeCreationSpec[]; -} - -// ============================================================= -// Execution Pipeline — Full experiment lifecycle -// ============================================================= - -export type ExperimentScale = "pilot" | "full" | "preprocess_only" | "eval_only" | "data_only"; -export type ExperimentStatus = - | "planning" - | "data_pending" - | "data_downloading" - | "data_ready" - | "preprocessing" - | "preprocess_ready" - | "submission_pending" - | "submitted" - | "running" - | "completed" - | "failed" - | "cancelled" - | "dry_run"; - -export type SubmissionMode = "real" | "dry_run" | "mock"; - -/** Complete experiment specification produced by the execution planner. */ -export interface ExperimentSpec { - experimentId: string; - sessionId: string; - name: string; - description: string; - scale: ExperimentScale; - status: ExperimentStatus; - - /** Task type classification. */ - taskType: string; - - /** Models used in the experiment. */ - models: string[]; - - /** Data sources to acquire. */ - dataSources: DataSourceSpec[]; - - /** Preprocessing pipeline. */ - preprocessing: PreprocessingPipelineSpec; - - /** Training/evaluation commands. */ - commands: ExperimentCommand[]; - - /** Resource requirements. */ - resources: ExperimentResources; - - /** Mount specifications. */ - mounts: MountSpec[]; - - /** Environment setup (modules, env vars, venv, etc.). */ - environment: EnvironmentSetup; - - /** Output configuration. */ - outputs: OutputConfig; - - /** Failure/retry policy. */ - retryPolicy: RetryPolicy; - - /** Submission mode. */ - submissionMode: SubmissionMode; - - /** Launcher type to use. */ - launcherType: LauncherType; - - /** Timestamps. */ - createdAt: string; - updatedAt: string; -} - -export interface DataSourceSpec { - id: string; - name: string; - source: "huggingface" | "github" | "url" | "local"; - identifier: string; - subset?: string; - split?: string; - revision?: string; - format?: string; - estimatedSizeGb: number; - cachePath: string; - /** Auth token env var name (not the token itself). */ - authTokenEnvVar?: string; -} - -export interface PreprocessingPipelineSpec { - enabled: boolean; - steps: PreprocessingStepSpec[]; - outputPath: string; - outputFormat: string; - /** Skip preprocessing if output already exists and inputs haven't changed. */ - skipIfCached: boolean; -} - -export interface PreprocessingStepSpec { - order: number; - name: string; - type: string; - config: Record<string, unknown>; - description: string; - /** Expected output path for caching. */ - outputPath?: string; - /** Version tag for cache invalidation. */ - version?: string; -} - -export interface ExperimentCommand { - name: string; - command: string; - args: string[]; - workingDir?: string; - /** Stage this command belongs to. */ - stage: "setup" | "train" | "eval" | "postprocess"; - /** Dependencies (other command names that must complete first). */ - dependsOn: string[]; -} - -export interface ExperimentResources { - gpu: number; - gpuType?: string; - cpu: number; - memoryMb: number; - diskGb?: number; - walltime: string; - privateMachine: "yes" | "no" | "group"; - maxWaitDuration?: string; -} - -export interface EnvironmentSetup { - modules: string[]; - envVars: Record<string, string>; - condaEnv?: string; - venvPath?: string; - setupCommands: string[]; - workingDir: string; -} - -export interface OutputConfig { - baseDir: string; - checkpointDir: string; - logDir: string; - metricsDir: string; - artifactPatterns: string[]; -} - -export interface RetryPolicy { - maxRetries: number; - retryOnOOM: boolean; - retryDelaySeconds: number; - /** Scale down resources on OOM. */ - scaleDownOnOOM: boolean; -} - -// --- Job submission --- - -export type JobStatus = "pending" | "queued" | "running" | "completed" | "failed" | "cancelled" | "unknown"; - -export interface JobSubmissionResult { - success: boolean; - jobId: string | null; - message: string; - submittedAt: string; - mode: SubmissionMode; - /** Full rendered spec for inspection. */ - renderedSpec: string; - /** Metadata from the submission. */ - metadata: Record<string, unknown>; -} - -export interface JobStatusResult { - jobId: string; - status: JobStatus; - exitCode?: number; - runningTimeSec?: number; - nodeList?: string[]; - message?: string; - queriedAt: string; -} - -// --- Dataset acquisition result --- - -export type DatasetAcquisitionStatus = "pending" | "downloading" | "ready" | "failed" | "skipped"; - -export interface DatasetAcquisitionResult { - sourceId: string; - source: DataSourceSpec; - status: DatasetAcquisitionStatus; - localPath: string; - sizeBytes?: number; - fileCount?: number; - checksum?: string; - downloadedAt?: string; - skippedReason?: string; - error?: string; - command: string; -} - -// --- Preprocessing run result --- - -export type PreprocessingStepStatus = "pending" | "running" | "completed" | "skipped" | "failed"; - -export interface PreprocessingStepResult { - stepName: string; - order: number; - status: PreprocessingStepStatus; - inputPath: string; - outputPath: string; - recordsIn?: number; - recordsOut?: number; - durationMs?: number; - skippedReason?: string; - error?: string; - configHash?: string; -} - -export interface PreprocessingRunResult { - experimentId: string; - pipelineName: string; - steps: PreprocessingStepResult[]; - overallStatus: PreprocessingStepStatus; - totalRecordsIn?: number; - totalRecordsOut?: number; - totalDurationMs: number; - outputPath: string; - manifestPath?: string; -} - -// --- Experiment manifest (reproducibility) --- - -export interface ExperimentManifest { - experimentId: string; - sessionId: string; - createdAt: string; - - /** Exact dataset versions used. */ - datasets: Array<{ - sourceId: string; - identifier: string; - revision?: string; - checksum?: string; - localPath: string; - }>; - - /** Preprocessing config used. */ - preprocessingConfig: PreprocessingPipelineSpec; - - /** Code version if available. */ - codeVersion?: string; - - /** Execution config. */ - executionConfig: { - resources: ExperimentResources; - environment: EnvironmentSetup; - commands: ExperimentCommand[]; - launcherType: LauncherType; - }; - - /** Job submission details. */ - jobSubmission?: JobSubmissionResult; - - /** Output paths. */ - outputPaths: OutputConfig; - - /** Evaluation results if available. */ - evaluationSummary?: Record<string, unknown>; - - /** Final status. */ - status: ExperimentStatus; - - /** Timestamps. */ - startedAt?: string; - completedAt?: string; -} - -// --- Dry-run result --- - -export interface DryRunResult { - experimentId: string; - mode: "dry_run"; - renderedJobSpec: string; - renderedCommands: string[]; - estimatedResources: ExperimentResources; - estimatedCost?: string; - dataRequirements: DataSourceSpec[]; - preprocessingSteps: PreprocessingStepSpec[]; - warnings: string[]; - blockers: string[]; - readyToSubmit: boolean; -} - -// --- Execution pipeline config --- - -export interface ExecutionPipelineConfig { - /** Root cache directory for datasets. */ - dataCacheDir: string; - /** Root directory for experiment outputs. */ - experimentOutputRoot: string; - /** Root directory for preprocessing outputs. */ - preprocessingOutputRoot: string; - /** Default launcher type. */ - defaultLauncherType: LauncherType; - /** Default resources. */ - defaultResources: ExperimentResources; - /** Default mounts. */ - defaultMounts: MountSpec[]; - /** Charged/billing group. */ - chargedGroup: string; - /** Default environment setup. */ - defaultEnvironment: Partial<EnvironmentSetup>; - /** Default retry policy. */ - defaultRetryPolicy: RetryPolicy; - /** Whether to skip dataset downloads if cache exists. */ - skipExistingData: boolean; - /** Whether to skip preprocessing if output exists and config unchanged. */ - skipExistingPreprocessing: boolean; -} - -export const DEFAULT_EXECUTION_PIPELINE_CONFIG: ExecutionPipelineConfig = { - dataCacheDir: "/mnt/shared-storage-user/suencheng/data-cache", - experimentOutputRoot: "/mnt/shared-storage-user/suencheng/experiments", - preprocessingOutputRoot: "/mnt/shared-storage-user/suencheng/preprocessed", - defaultLauncherType: "rjob", - defaultResources: { - gpu: 2, - gpuType: undefined, - cpu: 32, - memoryMb: 200_000, - walltime: "24:00:00", - privateMachine: "yes", - }, - defaultMounts: [ - { source: "gpfs://gpfs1/suencheng", target: "/mnt/shared-storage-user/suencheng" }, - { source: "gpfs://gpfs1/ai4sreason", target: "/mnt/shared-storage-user/ai4sreason" }, - ], - chargedGroup: "ai4sdata_gpu", - defaultEnvironment: { - modules: [], - envVars: {}, - setupCommands: [], - workingDir: "/mnt/shared-storage-user/suencheng", - }, - defaultRetryPolicy: { - maxRetries: 1, - retryOnOOM: true, - retryDelaySeconds: 60, - scaleDownOnOOM: false, - }, - skipExistingData: true, - skipExistingPreprocessing: true, -}; - -// ============================================================= -// Execution Loop — Worker Decomposition & Multi-Round Types -// ============================================================= - -/** How a parent experiment is decomposed into workers. */ -export type WorkerDecompositionStrategy = - | "seed_sweep" - | "hyperparameter_sweep" - | "benchmark_shard" - | "ablation" - | "model_variant" - | "replay_budget" - | "preprocessing_shard" - | "train_eval_split" - | "custom"; - -/** Status of a single worker run within an experiment group. */ -export type WorkerRunStatus = - | "pending" - | "queued" - | "running" - | "completed" - | "failed" - | "cancelled" - | "timeout"; - -/** A single worker run within an ExperimentGroup. */ -export interface WorkerRun { - workerId: string; - parentExperimentId: string; - groupId: string; - /** Human label, e.g. "seed=42" or "lr=1e-4". */ - label: string; - /** The spec for this specific worker (may override parent). */ - spec: ExperimentSpec; - /** Job ID returned by the backend after submission. */ - jobId: string | null; - status: WorkerRunStatus; - /** Worker-specific parameter overrides. */ - paramOverrides: Record<string, unknown>; - /** Collected metrics after completion. */ - metrics: Record<string, number>; - /** Collected artifact paths. */ - artifactPaths: string[]; - /** Logs (truncated). */ - logTail: string; - exitCode: number | null; - /** Runtime in seconds. */ - runtimeSec: number | null; - /** Error message if failed. */ - error: string | null; - submittedAt: string | null; - startedAt: string | null; - completedAt: string | null; - createdAt: string; -} - -/** A group of related worker runs forming one logical experiment. */ -export interface ExperimentGroup { - groupId: string; - sessionId: string; - /** Which execution round this group belongs to. */ - roundNumber: number; - /** Parent experiment spec (workers derive from this). */ - parentSpec: ExperimentSpec; - /** How the experiment was decomposed. */ - decompositionStrategy: WorkerDecompositionStrategy; - /** All worker runs in this group. */ - workers: WorkerRun[]; - /** Dependency graph: workerId → list of workerIds it depends on. */ - dependencyGraph: Record<string, string[]>; - /** Aggregation rules for combining worker results. */ - aggregationRules: AggregationRules; - /** Validation criteria for this group. */ - validationCriteria: ValidationCriteria; - /** Current group-level status. */ - status: "pending" | "running" | "completed" | "partially_failed" | "failed" | "cancelled"; - /** Aggregated result after all workers finish (or enough finish). */ - aggregatedResult: AggregatedResult | null; - createdAt: string; - completedAt: string | null; -} - -/** Rules for aggregating results across workers in a group. */ -export interface AggregationRules { - /** How to combine numeric metrics across workers. */ - metricAggregation: "mean" | "median" | "min" | "max" | "all"; - /** Required fraction of workers that must succeed (0-1). */ - minSuccessRate: number; - /** Metrics to aggregate. */ - metricsToAggregate: string[]; - /** Whether to compute variance across seeds. */ - computeVariance: boolean; - /** Max acceptable coefficient of variation across seeds. */ - maxCoefficientOfVariation: number | null; - /** Custom aggregation function name (for extensibility). */ - customAggregator: string | null; -} - -/** Combined result from aggregating multiple worker runs. */ -export interface AggregatedResult { - groupId: string; - totalWorkers: number; - succeededWorkers: number; - failedWorkers: number; - /** Aggregated metrics (key → {mean, std, min, max, values}). */ - metrics: Record<string, AggregatedMetric>; - /** All collected artifact paths across workers. */ - allArtifactPaths: string[]; - /** Per-worker summary. */ - workerSummaries: Array<{ - workerId: string; - label: string; - status: WorkerRunStatus; - metrics: Record<string, number>; - runtimeSec: number | null; - }>; - aggregatedAt: string; -} - -export interface AggregatedMetric { - mean: number; - std: number; - min: number; - max: number; - median: number; - values: number[]; - coefficientOfVariation: number; -} - -// ============================================================= -// Execution Validation -// ============================================================= - -export type ValidationVerdict = "pass" | "fail" | "inconclusive"; - -export interface ValidationCriteria { - /** Required metrics and their thresholds. */ - metricThresholds: Array<{ - metric: string; - operator: "gte" | "lte" | "gt" | "lt" | "eq" | "between"; - value: number; - upperBound?: number; - }>; - /** Required artifact patterns that must exist. */ - requiredArtifacts: string[]; - /** Min number of successful workers. */ - minSuccessfulWorkers: number; - /** Max acceptable variance across seeds. */ - maxVariance: number | null; - /** Whether baseline comparison is required. */ - baselineRequired: boolean; - /** Baseline metric values for comparison. */ - baselineMetrics: Record<string, number>; - /** Custom pass conditions (human-readable for LLM evaluation). */ - customConditions: string[]; -} - -export interface ExecutionValidationResult { - verdict: ValidationVerdict; - /** Overall score 0-1. */ - confidenceScore: number; - /** Per-criterion results. */ - criterionResults: Array<{ - criterion: string; - passed: boolean; - actual: string; - expected: string; - note: string; - }>; - /** What's missing. */ - missingArtifacts: string[]; - /** Metric comparison results. */ - metricComparisons: Array<{ - metric: string; - actual: number; - threshold: number; - operator: string; - passed: boolean; - }>; - /** Reasons for the verdict. */ - reasons: string[]; - /** Blockers preventing a pass. */ - blockers: string[]; - /** Suggestions for fixing failures. */ - retrySuggestion: string | null; - replanSuggestion: string | null; - /** Severity of failure. */ - severity: "none" | "minor" | "major" | "critical"; - validatedAt: string; -} - -// ============================================================= -// Experiment Analysis (for failed/inconclusive runs) -// ============================================================= - -export type ExperimentFailureCategory = - | "resource_underallocation" - | "launcher_failure" - | "data_issue" - | "preprocessing_bug" - | "metric_mismatch" - | "unstable_training" - | "incorrect_hypothesis" - | "missing_baseline" - | "implementation_bug" - | "negative_scientific_result" - | "timeout" - | "oom" - | "infrastructure_failure" - | "unknown"; - -export type ExperimentAnalysisRecommendation = - | "rerun_unchanged" - | "rerun_with_fixes" - | "redesign_experiment" - | "narrow_scope" - | "increase_resources" - | "fix_data_pipeline" - | "add_baseline" - | "stop_research" - | "pivot_hypothesis"; - -export interface ExperimentAnalysisResult { - analysisId: string; - groupId: string; - roundNumber: number; - /** Root cause candidates ranked by likelihood. */ - rootCauses: Array<{ - category: ExperimentFailureCategory; - description: string; - confidence: number; - supportingEvidence: string[]; - }>; - /** Top recommendation. */ - primaryRecommendation: ExperimentAnalysisRecommendation; - /** All recommendations with reasoning. */ - recommendations: Array<{ - action: ExperimentAnalysisRecommendation; - reasoning: string; - estimatedEffort: "low" | "medium" | "high"; - requiredChanges: string[]; - }>; - /** Whether to rerun unchanged. */ - shouldRerun: boolean; - /** Whether to redesign. */ - shouldRedesign: boolean; - /** Whether to stop entirely. */ - shouldStop: boolean; - /** Specific fixes if rerunning. */ - suggestedFixes: Array<{ - target: string; - fix: string; - priority: "critical" | "high" | "medium" | "low"; - }>; - /** Summary for Main Brain. */ - summaryForMainBrain: string; - analyzedAt: string; -} - -// ============================================================= -// Execution Round — Iterative Loop Tracking -// ============================================================= - -export interface ExecutionRound { - roundNumber: number; - sessionId: string; - /** The plan that was approved for this round. */ - planSnapshot: ValidationPlan; - /** Worker group for this round. */ - group: ExperimentGroup | null; - /** Validation result for this round. */ - validationResult: ExecutionValidationResult | null; - /** Analysis result if validation failed/inconclusive. */ - analysisResult: ExperimentAnalysisResult | null; - /** What changed from the previous round. */ - changesFromPrevious: string[]; - /** Whether Main Brain decided to continue after this round. */ - continueDecision: "continue" | "replan" | "stop" | "pending"; - /** Reason for the decision. */ - decisionReason: string; - status: "planning" | "executing" | "validating" | "analyzing" | "replanning" | "completed" | "stopped"; - startedAt: string; - completedAt: string | null; -} - -/** Full execution lineage across all rounds. */ -export interface ExecutionLineage { - sessionId: string; - rounds: ExecutionRound[]; - currentRound: number; - maxRounds: number; - /** Whether the hypothesis has been falsified. */ - hypothesisFalsified: boolean; - /** Count of consecutive failures (for stop condition). */ - consecutiveFailures: number; - /** Whether any round passed validation. */ - hasPassingRound: boolean; - /** Summary of evidence across all rounds. */ - cumulativeEvidence: string[]; -} - -// ============================================================= -// Remote Execution Configuration (SSH) -// ============================================================= - -export interface RemoteExecutionConfig { - /** Remote hostname or IP. */ - host: string; - /** SSH port. */ - port: number; - /** Username for SSH. */ - username: string; - /** Path to SSH private key (or "agent" to use ssh-agent). */ - keyPath: string; - /** Remote working directory. */ - remoteWorkDir: string; - /** Remote environment setup commands. */ - remoteSetupCommands: string[]; - /** Which launchers are available on the remote. */ - availableLaunchers: LauncherType[]; - /** Connection timeout in ms. */ - connectTimeoutMs: number; - /** Command timeout in ms. */ - commandTimeoutMs: number; -} - -export const DEFAULT_REMOTE_EXECUTION_CONFIG: RemoteExecutionConfig = { - host: "", - port: 22, - username: "", - keyPath: "agent", - remoteWorkDir: "/tmp/deep-research", - remoteSetupCommands: [], - availableLaunchers: ["rjob", "rlaunch"], - connectTimeoutMs: 30_000, - commandTimeoutMs: 600_000, -}; - -// ============================================================= -// Worker Fanout Plan (from execution planner) -// ============================================================= - -export interface WorkerFanoutPlan { - /** Parent experiment spec. */ - parentSpec: ExperimentSpec; - /** How to decompose. */ - strategy: WorkerDecompositionStrategy; - /** Parameter space to sweep. */ - parameterSpace: Array<{ - name: string; - values: unknown[]; - }>; - /** Total number of workers to create. */ - totalWorkers: number; - /** Maximum serialized worker slots. Deep research currently runs one at a time. */ - maxParallel: number; - /** Whether to run a pilot worker first. */ - pilotFirst: boolean; - /** Dependency structure. */ - dependencyType: "independent" | "sequential" | "staged_dag"; - /** Validation criteria. */ - validationCriteria: ValidationCriteria; - /** Aggregation rules. */ - aggregationRules: AggregationRules; - /** Estimated total GPU hours. */ - estimatedTotalGPUHours: number; - /** Resource template per worker. */ - perWorkerResources: ExperimentResources; -} - -// ============================================================= -// Extended Job Submission — Logs & Outputs -// ============================================================= - -export interface JobLogResult { - jobId: string; - stdout: string; - stderr: string; - truncated: boolean; - fetchedAt: string; -} - -export interface JobOutputResult { - jobId: string; - /** Discovered output files. */ - files: Array<{ - path: string; - sizeBytes: number; - isMetrics: boolean; - }>; - /** Parsed metrics if found. */ - metrics: Record<string, number>; - /** Raw metrics JSON content if found. */ - metricsRaw: string | null; - fetchedAt: string; -} +export * from "./status-types"; +export * from "./structured-types"; +export * from "./config-types"; +export * from "./record-types"; +export * from "./workflow-types"; diff --git a/src/lib/deep-research/worker-aggregator.ts b/src/lib/deep-research/worker-aggregator.ts deleted file mode 100644 index be103dae..00000000 --- a/src/lib/deep-research/worker-aggregator.ts +++ /dev/null @@ -1,195 +0,0 @@ -// ============================================================= -// Worker Aggregator — Combine results from multi-worker runs -// ============================================================= -// Aggregates metrics, artifacts, and status across all workers -// in an ExperimentGroup to produce a unified AggregatedResult. - -import type { - ExperimentGroup, - AggregatedResult, - AggregatedMetric, - AggregationRules, -} from "./types"; - -// ------------------------------------------------------------------- -// Main aggregation function -// ------------------------------------------------------------------- - -/** - * Aggregate results from all completed workers in a group. - */ -export function aggregateWorkerResults( - group: ExperimentGroup, - rules?: AggregationRules, -): AggregatedResult { - const effectiveRules = rules ?? group.aggregationRules; - const completedWorkers = group.workers.filter(w => w.status === "completed"); - const failedWorkers = group.workers.filter(w => w.status === "failed" || w.status === "timeout"); - - // Collect all metric keys across workers - const allMetricKeys = new Set<string>(); - for (const worker of completedWorkers) { - for (const key of Object.keys(worker.metrics)) { - allMetricKeys.add(key); - } - } - - // Filter to only configured metrics if specified - const metricsToAggregate = effectiveRules.metricsToAggregate.length > 0 - ? effectiveRules.metricsToAggregate - : Array.from(allMetricKeys); - - // Aggregate each metric - const metrics: Record<string, AggregatedMetric> = {}; - for (const metricKey of metricsToAggregate) { - const values = completedWorkers - .map(w => w.metrics[metricKey]) - .filter((v): v is number => v !== undefined && v !== null && isFinite(v)); - - if (values.length === 0) continue; - metrics[metricKey] = computeAggregatedMetric(values); - } - - // Collect all artifact paths - const allArtifactPaths = completedWorkers.flatMap(w => w.artifactPaths); - - // Build per-worker summaries - const workerSummaries = group.workers.map(w => ({ - workerId: w.workerId, - label: w.label, - status: w.status, - metrics: w.metrics, - runtimeSec: w.runtimeSec, - })); - - return { - groupId: group.groupId, - totalWorkers: group.workers.length, - succeededWorkers: completedWorkers.length, - failedWorkers: failedWorkers.length, - metrics, - allArtifactPaths, - workerSummaries, - aggregatedAt: new Date().toISOString(), - }; -} - -// ------------------------------------------------------------------- -// Metric computation -// ------------------------------------------------------------------- - -/** - * Compute aggregated statistics for a set of values. - */ -export function computeAggregatedMetric(values: number[]): AggregatedMetric { - if (values.length === 0) { - return { mean: 0, std: 0, min: 0, max: 0, median: 0, values: [], coefficientOfVariation: 0 }; - } - - const sorted = [...values].sort((a, b) => a - b); - const n = values.length; - const mean = values.reduce((s, v) => s + v, 0) / n; - const variance = n > 1 - ? values.reduce((s, v) => s + (v - mean) ** 2, 0) / (n - 1) - : 0; - const std = Math.sqrt(variance); - const median = n % 2 === 0 - ? (sorted[n / 2 - 1] + sorted[n / 2]) / 2 - : sorted[Math.floor(n / 2)]; - const coefficientOfVariation = mean !== 0 ? std / Math.abs(mean) : 0; - - return { - mean, - std, - min: sorted[0], - max: sorted[n - 1], - median, - values: [...values], - coefficientOfVariation, - }; -} - -// ------------------------------------------------------------------- -// Group status computation -// ------------------------------------------------------------------- - -/** - * Determine the overall status of an experiment group based on its workers. - */ -export function computeGroupStatus( - group: ExperimentGroup, -): ExperimentGroup["status"] { - const statuses = group.workers.map(w => w.status); - const total = statuses.length; - - if (total === 0) return "pending"; - - const completed = statuses.filter(s => s === "completed").length; - const failed = statuses.filter(s => s === "failed" || s === "timeout").length; - const running = statuses.filter(s => s === "running" || s === "queued").length; - const pending = statuses.filter(s => s === "pending").length; - const cancelled = statuses.filter(s => s === "cancelled").length; - - if (cancelled === total) return "cancelled"; - if (running > 0 || pending > 0) return "running"; - if (completed === total) return "completed"; - if (failed === total) return "failed"; - if (completed > 0 && failed > 0) return "partially_failed"; - if (failed > 0) return "failed"; - - return "completed"; -} - -// ------------------------------------------------------------------- -// Summary generation -// ------------------------------------------------------------------- - -/** - * Generate a human-readable summary of aggregated results. - */ -export function summarizeAggregatedResult(result: AggregatedResult): string { - const lines: string[] = []; - - lines.push(`## Experiment Group: ${result.groupId}`); - lines.push(`Workers: ${result.succeededWorkers}/${result.totalWorkers} succeeded, ${result.failedWorkers} failed`); - lines.push(""); - - if (Object.keys(result.metrics).length > 0) { - lines.push("### Aggregated Metrics"); - for (const [key, metric] of Object.entries(result.metrics)) { - lines.push( - `- **${key}**: mean=${metric.mean.toFixed(4)}, std=${metric.std.toFixed(4)}, ` + - `range=[${metric.min.toFixed(4)}, ${metric.max.toFixed(4)}], CV=${metric.coefficientOfVariation.toFixed(3)}` - ); - } - lines.push(""); - } - - if (result.workerSummaries.length > 0) { - lines.push("### Per-Worker Summary"); - for (const ws of result.workerSummaries) { - const metricsStr = Object.entries(ws.metrics).map(([k, v]) => `${k}=${v.toFixed(4)}`).join(", "); - lines.push(`- ${ws.label} [${ws.status}]: ${metricsStr || "no metrics"} (${ws.runtimeSec ?? "?"}s)`); - } - } - - return lines.join("\n"); -} - -// ------------------------------------------------------------------- -// Default aggregation rules -// ------------------------------------------------------------------- - -export function createDefaultAggregationRules( - overrides?: Partial<AggregationRules>, -): AggregationRules { - return { - metricAggregation: "mean", - minSuccessRate: 0.5, - metricsToAggregate: [], - computeVariance: true, - maxCoefficientOfVariation: null, - customAggregator: null, - ...overrides, - }; -} diff --git a/src/lib/deep-research/workflow-policy.ts b/src/lib/deep-research/workflow-policy.ts index d3abd9c4..ecd27d95 100644 --- a/src/lib/deep-research/workflow-policy.ts +++ b/src/lib/deep-research/workflow-policy.ts @@ -105,6 +105,7 @@ export function deriveWorkflowPolicy(input: { mode === "analysis_only" ? `- Experimental execution is blocked unless the user later explicitly requests it. Blocked node types: ${Array.from(blockedNodeTypes).join(", ")}` : "- Experimental execution is allowed when justified by the confirmed plan.", + "- For conceptual framework design, taxonomy building, architecture comparison, or literature-structuring tasks, use summarize/review/final_report style nodes rather than validation_plan.", "- On the first planning pass, you MUST produce a complete plan in messageToUser, explain which phases are needed, and explicitly name any phases you are skipping.", "- Do not treat experiment design/execution as mandatory. Choose only the phases required by the user's question and the workstation evidence.", ].join("\n"); diff --git a/src/lib/deep-research/workflow-types.ts b/src/lib/deep-research/workflow-types.ts new file mode 100644 index 00000000..298a26ad --- /dev/null +++ b/src/lib/deep-research/workflow-types.ts @@ -0,0 +1,461 @@ +import type { + ArtifactType, + ContextTag, + ModelRole, + NodeType, +} from "./status-types"; +import type { StructuredPromptKind } from "./structured-types"; + +export interface ReviewAssessment { + reviewerRole?: "results_and_evidence_analyst"; + reviewerSummary?: string; + reviewHighlights?: string[]; + openIssues?: string[]; + reviewRounds?: number; + combinedVerdict: "approve" | "revise" | "reject"; + combinedConfidence: number; + uncertaintyReducers: string[]; + needsMoreLiterature: boolean; + literatureGaps: string[]; + needsExperimentalValidation: boolean; + suggestedExperiments: string[]; +} + +export interface AlternativeAction { + label: string; + description: string; + actionType: "continue" | "revise" | "retry" | "more_literature" | "fix_code" | "change_params" | "more_resources" | "stop"; +} + +export interface MainBrainAudit { + whatWasCompleted: string; + resultAssessment: "good" | "acceptable" | "concerning" | "problematic"; + issuesAndRisks: string[]; + recommendedNextAction: string; + continueWillDo: string; + alternativeActions: AlternativeAction[]; + canProceed: boolean; +} + +export interface BrainDecision { + action: "advance_context" | "revise_plan" | "request_approval" | "complete" | "respond_to_user"; + nextContextTag?: ContextTag; + nodesToCreate?: NodeCreationSpec[]; + messageToUser?: string; + reasoning?: string; +} + +export type CheckpointInteractionMode = "confirmation" | "answer_required"; + +export interface NodeCreationSpec { + nodeType: NodeType; + label: string; + assignedRole: ModelRole; + input?: Record<string, unknown>; + dependsOn?: string[]; + parentId?: string; + branchKey?: string; + contextTag?: ContextTag; +} + +export interface TransitionAction { + nextContextTag: ContextTag; + nodesToCreate: NodeCreationSpec[]; + nodesToSupersede: string[]; + description: string; +} + +export interface CheckpointPackage { + checkpointId: string; + sessionId: string; + nodeId: string; + stepType: string; + contextTag: ContextTag; + title: string; + humanSummary: string; + machineSummary: string; + mainBrainAudit: MainBrainAudit; + artifactsToReview: string[]; + currentFindings: string; + openQuestions: string[]; + recommendedNextAction: string; + recommendedWorker?: { + roleId: ModelRole; + roleName: string; + nodeType: NodeType; + label: string; + }; + promptUsed?: { + title: string; + kind: StructuredPromptKind; + objective: string; + }; + continueWillDo: string; + alternativeNextActions: string[]; + requiresUserConfirmation: boolean; + interactionMode?: CheckpointInteractionMode; + isFinalStep?: boolean; + transitionAction?: TransitionAction; + literatureRoundInfo?: { + roundNumber: number; + papersCollected: number; + retrievalTaskCount: number; + successfulTaskCount: number; + failedTaskCount: number; + emptyTaskCount: number; + coverageSummary: string; + }; + reviewInfo?: ReviewAssessment; + executionInfo?: { + stepsCompleted: number; + stepsTotal: number; + currentStatus: string; + }; + createdAt: string; +} + +export type ConfirmationAction = + | "continue" + | "revise" + | "retry" + | "branch" + | "supersede" + | "stop"; + +export interface ConfirmationDecision { + action: ConfirmationAction; + reasoning: string; + nodesToCreate?: NodeCreationSpec[]; + nextContextTag?: ContextTag; + messageToUser?: string; +} + +export type RequirementStatus = "active" | "satisfied" | "dropped"; +export type ConstraintType = "budget" | "time" | "scope" | "method" | "resource"; +export type ConstraintStatus = "active" | "relaxed" | "violated"; + +export interface Requirement { + id: string; + text: string; + source: string; + priority: "critical" | "high" | "medium" | "low"; + status: RequirementStatus; + satisfiedByNodeIds: string[]; + addedAtContextTag: ContextTag; +} + +export interface Constraint { + id: string; + text: string; + type: ConstraintType; + value: string; + status: ConstraintStatus; + addedAtContextTag: ContextTag; +} + +export interface RequirementState { + requirements: Requirement[]; + constraints: Constraint[]; + version: number; + lastModifiedAt: string; + lastModifiedBy: string; + originalUserGoal: string; + currentApprovedGoal: string; + latestUserInstruction: string | null; + approvedResearchScope: string | null; + approvedExperimentScope: string | null; + executionAllowed: boolean; + latestMainBrainAcceptedInterpretation: string | null; + supersedesVersion: number | null; +} + +export interface RequirementDiff { + added: Requirement[]; + removed: Requirement[]; + modified: Array<{ id: string; field: string; oldValue: unknown; newValue: unknown }>; + constraintsChanged: boolean; +} + +export type ExecutionRecordType = "rlaunch" | "rjob" | "local"; +export type ExecutionRecordStatus = "pending" | "submitted" | "running" | "completed" | "failed" | "cancelled"; + +export interface PersistedExecutionRecord { + id: string; + sessionId: string; + nodeId: string; + recordType: ExecutionRecordType; + status: ExecutionRecordStatus; + remoteJobId: string | null; + remoteHost: string | null; + command: string; + configJson: Record<string, unknown>; + resultJson: Record<string, unknown> | null; + submittedAt: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export type DAGErrorType = "cycle" | "orphan" | "dangling" | "duplicate"; + +export interface DAGError { + type: DAGErrorType; + nodeIds: string[]; + message: string; +} + +export interface DAGValidationResult { + valid: boolean; + errors: DAGError[]; +} + +export interface ConsistencyReport { + valid: boolean; + warnings: string[]; + errors: string[]; +} + +export interface LanguageState { + currentUserLanguage: string; + preferredOutputLanguage: string; + lastDetectedUserLanguage: string; + lastLanguageUpdateAt: string; +} + +export type EvidenceRetrievalStatus = + | "success" + | "partial" + | "failed_retrieval" + | "insufficient_evidence" + | "empty"; + +export interface EvidenceSufficiencyReport { + sufficient: boolean; + streams: Array<{ + nodeId: string; + label: string; + status: EvidenceRetrievalStatus; + sourcesFound: number; + failureReason?: string; + }>; + totalSources: number; + failedStreams: number; + canSynthesize: boolean; + missingTopics: string[]; +} + +export interface RawExcerpt { + text: string; + sourceIndex: number; + page?: string; + section?: string; +} + +export interface SourceEntry { + title: string; + url: string; + authors?: string[]; + year?: number; + venue?: string; + doi?: string; + retrievalMethod: string; + retrievedAt: string; +} + +export interface EvidenceCard { + id: string; + query: string; + sources: SourceEntry[]; + rawExcerpts: RawExcerpt[]; + retrievalStatus: EvidenceRetrievalStatus; + sourcesFound: number; + sourcesAttempted: number; + retrievalNotes: string; + createdAt: string; +} + +export interface EvidenceCardCollection { + cards: EvidenceCard[]; + totalSources: number; + totalExcerpts: number; + retrievalSummary: { + successful: number; + partial: number; + failed: number; + empty: number; + }; +} + +export type ClaimStrength = "strong" | "moderate" | "weak" | "unsupported"; + +export interface Claim { + id: string; + text: string; + strength: ClaimStrength; + supportingSources: number[]; + contradictingSources: number[]; + category: string; + knowledgeType: "retrieved_evidence" | "background_knowledge" | "assumption" | "speculation"; +} + +export interface Contradiction { + claimAId: string; + claimBId: string; + description: string; + possibleResolution: string; +} + +export interface GapAnalysis { + topic: string; + description: string; + suggestedQueries: string[]; + priority: "high" | "medium" | "low"; +} + +export interface ClaimMap { + claims: Claim[]; + supportMatrix: Record<string, number[]>; + contradictions: Contradiction[]; + gaps: GapAnalysis[]; + confidenceDistribution: Record<ClaimStrength, number>; +} + +export interface ChapterPacketQuote { + citationKey: string; + sourceTitle: string; + quote: string; + relevance: string; + year?: number; + url?: string; +} + +export interface ChapterPacketClaim { + id: string; + text: string; + strength: ClaimStrength; + citationKeys: string[]; + supportingSourceTitles: string[]; + counterpoints: string[]; +} + +export interface ChapterPacket { + id: string; + title: string; + objective: string; + summary: string; + keyTakeaways: string[]; + claims: ChapterPacketClaim[]; + supportingQuotes: ChapterPacketQuote[]; + citationKeys: string[]; + openQuestions: string[]; + recommendedSectionText: string; +} + +export interface StructuredSummaryArtifactContent { + summary: string; + chapterPackets: ChapterPacket[]; + crossSectionThemes: string[]; + globalOpenQuestions: string[]; + citationKeys: string[]; + recommendedReportNarrative?: string; +} + +export type ResearchMemoryKind = "semantic" | "episodic" | "procedural"; +export type ResearchMemoryStatus = "active" | "superseded" | "archived"; + +export type ResearchMemoryCategory = + | "user_goal" + | "constraint" + | "evidence" + | "claim" + | "gap" + | "decision" + | "execution" + | "workflow"; + +export interface ResearchMemoryAnchor { + artifactId?: string; + artifactType?: ArtifactType; + nodeId?: string; + messageId?: string; + sourceIndex?: number; + excerptIndex?: number; + claimId?: string; + gapIndex?: number; + field?: string; + note?: string; +} + +export interface ResearchMemoryItem { + id: string; + kind: ResearchMemoryKind; + category: ResearchMemoryCategory; + title: string; + summary: string; + details?: string; + tags: string[]; + keywords: string[]; + importance: number; + confidence: number; + status: ResearchMemoryStatus; + createdAt: string; + updatedAt: string; + provenance: { + sourceType: "artifact" | "message" | "event" | "derived"; + artifactId?: string; + nodeId?: string; + eventId?: string; + messageId?: string; + }; + anchors?: ResearchMemoryAnchor[]; + relatedMemoryIds?: string[]; +} + +export interface ResearchMemoryProfile { + sessionId: string; + generatedAt: string; + objective: string; + currentPhase: ContextTag; + latestCheckpointTitle?: string; + latestRecommendedNextAction?: string; + activeRequirements: string[]; + activeConstraints: string[]; + openQuestions: string[]; + activeHypotheses: string[]; + latestPlanSummary?: string; + keyDecisions: string[]; +} + +export interface ResearchMemorySnapshot { + sessionId: string; + generatedAt: string; + title: string; + summary: string; + acceptedFacts: string[]; + contestedFacts: string[]; + unresolvedGaps: string[]; + nextStep: string; + focusAreas: string[]; + relatedArtifactIds: string[]; +} + +export interface ResearchMemoryIndex { + sessionId: string; + generatedAt: string; + itemCount: number; + sourceOfTruth: "artifacts_and_messages"; + items: ResearchMemoryItem[]; + stats: { + semanticCount: number; + episodicCount: number; + proceduralCount: number; + activeCount: number; + }; +} + +export interface ResearchMemoryRetrievalResult { + profile: ResearchMemoryProfile; + snapshot: ResearchMemorySnapshot | null; + items: Array<ResearchMemoryItem & { retrievalScore: number }>; + query: string; +} diff --git a/src/lib/dev/project-filesystem.test.ts b/src/lib/dev/project-filesystem.test.ts new file mode 100644 index 00000000..15f5af1e --- /dev/null +++ b/src/lib/dev/project-filesystem.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; + +import { + assessPathLocking, + assessDistDirLocking, + findMountForPath, + parseLinuxMountInfo, + resolveNextBuildDir, +} from "./project-filesystem"; + +describe("project-filesystem", () => { + it("parses linux mountinfo entries", () => { + const mounts = parseLinuxMountInfo( + [ + "145 128 0:44 / / rw,relatime - overlay overlay rw,lowerdir=/lower,upperdir=/upper,workdir=/work", + "201 145 0:49 / /mnt/data rw,relatime - nfs 10.0.0.1:/share rw,vers=3,local_lock=none", + ].join("\n") + ); + + expect(mounts).toHaveLength(2); + expect(mounts[1]).toMatchObject({ + mountPoint: "/mnt/data", + fsType: "nfs", + }); + expect(mounts[1].superOptions).toContain("local_lock=none"); + }); + + it("finds the most specific mount for a path", () => { + const mounts = parseLinuxMountInfo( + [ + "145 128 0:44 / / rw,relatime - overlay overlay rw", + "201 145 0:49 / /mnt rw,relatime - nfs 10.0.0.1:/mnt rw,vers=3", + "202 201 0:50 / /mnt/data rw,relatime - nfs 10.0.0.1:/data rw,vers=3,local_lock=none", + ].join("\n") + ); + + const mount = findMountForPath("/mnt/data/project", mounts); + + expect(mount?.mountPoint).toBe("/mnt/data"); + expect(mount?.fsType).toBe("nfs"); + }); + + it("disables dist-dir locking on lockless network filesystems", () => { + const assessment = assessDistDirLocking( + "/mnt/data/project", + undefined, + "202 201 0:50 / /mnt/data rw,relatime - nfs 10.0.0.1:/data rw,vers=3,local_lock=none" + ); + + expect(assessment.disableLock).toBe(true); + expect(assessment.reason).toContain("filesystem type is nfs"); + expect(assessment.reason).toContain("mount option local_lock=none"); + }); + + it("keeps dist-dir locking on normal local filesystems", () => { + const assessment = assessDistDirLocking( + "/workspace/project", + undefined, + "145 128 0:44 / /workspace rw,relatime - ext4 /dev/sda1 rw" + ); + + expect(assessment.disableLock).toBe(false); + }); + + it("assesses the configured dist dir instead of the project root", () => { + const assessment = assessDistDirLocking( + "/workspace/project", + ".next-local", + [ + "145 128 0:44 / /workspace/project rw,relatime - nfs 10.0.0.1:/project rw,vers=3,local_lock=none", + "146 145 0:45 / /workspace/project/.next-local rw,relatime - ext4 /dev/sda1 rw", + ].join("\n") + ); + + expect(assessment.disableLock).toBe(false); + expect(assessment.mount?.mountPoint).toBe("/workspace/project/.next-local"); + }); + + it("assesses arbitrary paths on lockless mounts", () => { + const assessment = assessPathLocking( + "/mnt/data/project/data/innoclaw.db", + "202 201 0:50 / /mnt/data rw,relatime - nfs 10.0.0.1:/data rw,vers=3,local_lock=none" + ); + + expect(assessment.disableLock).toBe(true); + expect(assessment.mount?.mountPoint).toBe("/mnt/data"); + }); + + it("normalizes in-project NEXT_BUILD_DIR values", () => { + const resolved = resolveNextBuildDir( + "/workspace/project", + "/workspace/project/.next-local" + ); + + expect(resolved).toEqual({ distDir: ".next-local" }); + }); + + it("rejects NEXT_BUILD_DIR values that escape the project root", () => { + const resolved = resolveNextBuildDir( + "/workspace/project", + "/tmp/innoclaw-next" + ); + + expect(resolved.distDir).toBeUndefined(); + expect(resolved.warning).toContain("requires distDir to stay within the project root"); + }); + + it("rejects NEXT_BUILD_DIR values that point at the project root itself", () => { + const resolved = resolveNextBuildDir("/workspace/project", "."); + + expect(resolved.distDir).toBeUndefined(); + expect(resolved.warning).toContain("project root"); + }); +}); diff --git a/src/lib/dev/project-filesystem.ts b/src/lib/dev/project-filesystem.ts new file mode 100644 index 00000000..f16d2ea0 --- /dev/null +++ b/src/lib/dev/project-filesystem.ts @@ -0,0 +1,199 @@ +import fs from "node:fs"; +import path from "node:path"; + +export type LinuxMountInfo = { + mountPoint: string; + fsType: string; + mountOptions: string[]; + superOptions: string[]; +}; + +export type DistDirLockAssessment = { + disableLock: boolean; + mount?: LinuxMountInfo; + reason?: string; +}; + +export type ResolvedNextBuildDir = { + distDir?: string; + warning?: string; +}; + +const LOCKLESS_OPTIONS = new Set(["local_lock=none", "nolock"]); +const NETWORK_FILESYSTEM_TYPES = new Set([ + "9p", + "afs", + "ceph", + "cifs", + "fuse.sshfs", + "glusterfs", + "lustre", + "nfs", + "nfs4", + "smb3", + "sshfs", +]); + +function decodeMountPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_, octal: string) => + String.fromCharCode(Number.parseInt(octal, 8)) + ); +} + +function isPathWithinMount(targetPath: string, mountPoint: string): boolean { + const normalizedTarget = path.resolve(targetPath); + const normalizedMountPoint = path.resolve(mountPoint); + + if (normalizedTarget === normalizedMountPoint) { + return true; + } + + const prefix = normalizedMountPoint.endsWith(path.sep) + ? normalizedMountPoint + : `${normalizedMountPoint}${path.sep}`; + + return normalizedTarget.startsWith(prefix); +} + +function escapesProjectRoot(relativePath: string): boolean { + return ( + relativePath === ".." || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ); +} + +export function parseLinuxMountInfo(content: string): LinuxMountInfo[] { + return content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + const [beforeSeparator, afterSeparator] = line.split(" - "); + + if (!beforeSeparator || !afterSeparator) { + return []; + } + + const beforeFields = beforeSeparator.split(" "); + const afterFields = afterSeparator.split(" "); + + if (beforeFields.length < 6 || afterFields.length < 3) { + return []; + } + + return [ + { + mountPoint: decodeMountPath(beforeFields[4]), + fsType: afterFields[0], + mountOptions: beforeFields[5].split(",").filter(Boolean), + superOptions: afterFields[2].split(",").filter(Boolean), + }, + ]; + }); +} + +export function findMountForPath( + targetPath: string, + mounts: LinuxMountInfo[] +): LinuxMountInfo | null { + let bestMatch: LinuxMountInfo | null = null; + + for (const mount of mounts) { + if (!isPathWithinMount(targetPath, mount.mountPoint)) { + continue; + } + + if (!bestMatch || mount.mountPoint.length > bestMatch.mountPoint.length) { + bestMatch = mount; + } + } + + return bestMatch; +} + +export function assessPathLocking( + targetPath: string, + mountInfoContent?: string +): DistDirLockAssessment { + if (!mountInfoContent && process.platform !== "linux") { + return { disableLock: false }; + } + + try { + const mounts = parseLinuxMountInfo( + mountInfoContent ?? fs.readFileSync("/proc/self/mountinfo", "utf8") + ); + const mount = findMountForPath(targetPath, mounts); + + if (!mount) { + return { disableLock: false }; + } + + const options = new Set( + [...mount.mountOptions, ...mount.superOptions].map((option) => + option.toLowerCase() + ) + ); + + const reasons: string[] = []; + if (NETWORK_FILESYSTEM_TYPES.has(mount.fsType.toLowerCase())) { + reasons.push(`filesystem type is ${mount.fsType}`); + } + + for (const option of LOCKLESS_OPTIONS) { + if (options.has(option)) { + reasons.push(`mount option ${option}`); + } + } + + return { + disableLock: reasons.length > 0, + mount, + reason: reasons.join("; "), + }; + } catch { + return { disableLock: false }; + } +} + +export function assessDistDirLocking( + projectDir: string, + distDir?: string, + mountInfoContent?: string +): DistDirLockAssessment { + const distDirPath = path.resolve(projectDir, distDir ?? ".next"); + return assessPathLocking(distDirPath, mountInfoContent); +} + +export function resolveNextBuildDir( + projectDir: string, + rawValue: string | undefined +): ResolvedNextBuildDir { + const trimmedValue = rawValue?.trim(); + + if (!trimmedValue) { + return {}; + } + + const candidatePath = path.resolve(projectDir, trimmedValue); + const relativePath = path.relative(projectDir, candidatePath); + + if (!relativePath) { + return { + warning: + "Ignoring NEXT_BUILD_DIR because it resolves to the project root. Use a subdirectory such as .next-local.", + }; + } + + if (escapesProjectRoot(relativePath)) { + return { + warning: + `Ignoring NEXT_BUILD_DIR=${trimmedValue} because Next.js/Turbopack requires distDir to stay within the project root.`, + }; + } + + return { + distDir: relativePath, + }; +} diff --git a/src/lib/env-file.test.ts b/src/lib/env-file.test.ts index f256c99d..fbf7c7e7 100644 --- a/src/lib/env-file.test.ts +++ b/src/lib/env-file.test.ts @@ -51,6 +51,16 @@ describe("ensureEnvLocal", () => { const content = fs.readFileSync(path.join(tmpDir, ".env.local"), "utf-8"); expect(content).toContain("# InnoClaw configuration"); }); + + it("returns false instead of throwing when startup cannot create .env.local", async () => { + const { ensureEnvLocalForStartup } = await import("./env-file"); + + expect( + ensureEnvLocalForStartup(() => { + throw new Error("EACCES"); + }) + ).toBe(false); + }); }); describe("updateEnvLocal", () => { diff --git a/src/lib/env-file.ts b/src/lib/env-file.ts index 818c9497..d5324bc5 100644 --- a/src/lib/env-file.ts +++ b/src/lib/env-file.ts @@ -67,6 +67,24 @@ export function ensureEnvLocal(): void { } } +/** + * Best-effort startup wrapper for ensureEnvLocal(). + * Returns true when the file exists or is created, false when startup should + * continue without a writable env file. + */ +export function ensureEnvLocalForStartup( + createEnvLocal: () => void = ensureEnvLocal +): boolean { + try { + createEnvLocal(); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[env-file] Skipping .env.local bootstrap: ${message}`); + return false; + } +} + /** * Update (or append) key=value pairs in `.env.local`. * diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts index dc99ca19..5aaff434 100644 --- a/src/lib/fetcher.ts +++ b/src/lib/fetcher.ts @@ -1,5 +1,5 @@ export async function fetcher<T>(url: string): Promise<T> { - const res = await fetch(url); + const res = await fetch(url, { credentials: "include" }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `Request failed: ${url} ${res.status}`); @@ -8,4 +8,4 @@ export async function fetcher<T>(url: string): Promise<T> { } /** Simple JSON fetcher for useSWR — no error body extraction. */ -export const swrFetcher = (url: string) => fetch(url).then((r) => r.json()); +export const swrFetcher = (url: string) => fetch(url, { credentials: "include" }).then((r) => r.json()); diff --git a/src/lib/files/text-extractor-runtime.test.ts b/src/lib/files/text-extractor-runtime.test.ts new file mode 100644 index 00000000..b52d7909 --- /dev/null +++ b/src/lib/files/text-extractor-runtime.test.ts @@ -0,0 +1,19 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("text-extractor startup", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + }); + + it("does not load the pdf parser during module import", async () => { + vi.doMock("./pdf-parser", () => { + throw new Error("pdf parser should not load during module import"); + }); + + const imported = await import("./text-extractor"); + + expect(imported.extractText).toEqual(expect.any(Function)); + expect(imported.isSupportedFile).toEqual(expect.any(Function)); + }); +}); diff --git a/src/lib/files/text-extractor.ts b/src/lib/files/text-extractor.ts index cf481b50..f1950491 100644 --- a/src/lib/files/text-extractor.ts +++ b/src/lib/files/text-extractor.ts @@ -1,6 +1,5 @@ import * as cheerio from "cheerio"; import { readFileBuffer, readFile } from "./filesystem"; -import { extractPdfText } from "./pdf-parser"; import path from "path"; import { ALL_TEXT_EXTS } from "@/lib/constants"; @@ -29,6 +28,7 @@ export async function extractText(filePath: string): Promise<string> { switch (ext) { case ".pdf": { const buffer = await readFileBuffer(filePath); + const { extractPdfText } = await import("./pdf-parser"); return extractPdfText(buffer); } diff --git a/src/lib/hooks/use-auth.ts b/src/lib/hooks/use-auth.ts new file mode 100644 index 00000000..da6b3138 --- /dev/null +++ b/src/lib/hooks/use-auth.ts @@ -0,0 +1,17 @@ +import useSWR from "swr"; +import type { PublicUser } from "@/types/auth"; +import { fetcher } from "@/lib/fetcher"; + +export function useAuthUser() { + const { data, error, isLoading, mutate } = useSWR<{ user: PublicUser }>( + "/api/auth/me", + fetcher, + ); + + return { + user: data?.user ?? null, + isLoading, + error, + mutate, + }; +} diff --git a/src/lib/hooks/use-cost-tracking.ts b/src/lib/hooks/use-cost-tracking.ts new file mode 100644 index 00000000..0eefb754 --- /dev/null +++ b/src/lib/hooks/use-cost-tracking.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useRef, useState, useEffect } from "react"; +import type { UIMessage } from "ai"; +import { CostTracker, type CostSnapshot } from "@/lib/agent/cost-tracker"; +import { getMessageText } from "@/components/agent/message-utils"; + +interface UseCostTrackingOptions { + storageKey: string; + messages: UIMessage[]; + status: string; + resolvedModel: string; +} + +export function useCostTracking({ + storageKey, + messages, + status, + resolvedModel, +}: UseCostTrackingOptions) { + const costTrackerRef = useRef(new CostTracker()); + const [costSnapshot, setCostSnapshot] = useState<CostSnapshot | null>(null); + const prevMessageCountRef = useRef(0); + + // Restore cost tracker from localStorage on mount / key change + useEffect(() => { + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const data = JSON.parse(saved) as CostSnapshot; + costTrackerRef.current = CostTracker.fromJSON(data); + setCostSnapshot(costTrackerRef.current.getSnapshot()); + } else { + costTrackerRef.current = new CostTracker(); + setCostSnapshot(null); + } + } catch { + costTrackerRef.current = new CostTracker(); + setCostSnapshot(null); + } + // Sync message counter so already-persisted messages are not re-counted + prevMessageCountRef.current = messages.length; + }, [storageKey]); // eslint-disable-line react-hooks/exhaustive-deps -- messages intentionally read once at restore + + // Estimate cost from messages using character-based token estimation (~4 chars/token) + useEffect(() => { + if (status === "streaming" || status === "submitted") return; + if (messages.length <= prevMessageCountRef.current) { + prevMessageCountRef.current = messages.length; + return; + } + const newMessages = messages.slice(prevMessageCountRef.current); + prevMessageCountRef.current = messages.length; + + let inputChars = 0; + let outputChars = 0; + for (const msg of newMessages) { + const textLen = getMessageText(msg).length; + if (msg.role === "user") inputChars += textLen; + else outputChars += textLen; + } + + if (inputChars + outputChars > 0) { + const model = resolvedModel ?? "unknown"; + costTrackerRef.current.addUsage(model, { + inputTokens: Math.ceil(inputChars / 4), + outputTokens: Math.ceil(outputChars / 4), + }); + const snapshot = costTrackerRef.current.getSnapshot(); + setCostSnapshot(snapshot); + try { + localStorage.setItem(storageKey, JSON.stringify(snapshot)); + } catch { /* ignore */ } + } + }, [messages, status, resolvedModel, storageKey]); + + return { costSnapshot }; +} diff --git a/src/lib/hooks/use-deep-research.ts b/src/lib/hooks/use-deep-research.ts index fdff7720..b348603b 100644 --- a/src/lib/hooks/use-deep-research.ts +++ b/src/lib/hooks/use-deep-research.ts @@ -9,7 +9,14 @@ import type { DeepResearchEvent, PersistedExecutionRecord, } from "@/lib/deep-research/types"; -import { isCompletedSessionStatus } from "@/lib/deep-research/session-status"; +import { + ACTIVE_DEEP_RESEARCH_REFRESH_MS, + IDLE_DEEP_RESEARCH_REFRESH_MS, + getArtifactRefreshInterval, + getExecutionRefreshInterval, + getFullSessionRefreshInterval, + getSessionRefreshInterval, +} from "@/lib/deep-research/refresh-policy"; type DeepResearchResourceResult<T> = { data: T | undefined; @@ -61,7 +68,7 @@ export function useDeepResearchSessions(workspaceId: string | undefined) { : null; const { data, error, isLoading, mutate } = useDeepResearchListResource<DeepResearchSession>( url, - 30_000, + IDLE_DEEP_RESEARCH_REFRESH_MS, ); return { @@ -76,15 +83,7 @@ export function useDeepResearchSession(sessionId: string | undefined) { const url = sessionId ? `/api/deep-research/sessions/${sessionId}` : null; const { data, error, isLoading, mutate } = useDeepResearchResource<DeepResearchSession>(url, { - refreshInterval: (latestData) => { - if (!latestData) return 5000; - return isCompletedSessionStatus(latestData.status) - || latestData.status === "stopped_by_user" - || latestData.status === "failed" - || latestData.status === "cancelled" - ? 30_000 - : 5_000; - }, + refreshInterval: (latestData) => getSessionRefreshInterval(latestData ?? null), }); return { @@ -99,7 +98,7 @@ export function useDeepResearchMessages(sessionId: string | undefined) { const url = sessionId ? `/api/deep-research/sessions/${sessionId}/messages` : null; const { data, error, isLoading, mutate } = useDeepResearchListResource<DeepResearchMessage>( url, - 2_000, + ACTIVE_DEEP_RESEARCH_REFRESH_MS, ); return { @@ -114,7 +113,7 @@ export function useDeepResearchNodes(sessionId: string | undefined) { const url = sessionId ? `/api/deep-research/sessions/${sessionId}/nodes` : null; const { data, error, isLoading, mutate } = useDeepResearchListResource<DeepResearchNode>( url, - 2_000, + ACTIVE_DEEP_RESEARCH_REFRESH_MS, ); return { @@ -134,7 +133,7 @@ export function useDeepResearchArtifacts( : null; const { data, error, isLoading, mutate } = useDeepResearchListResource<DeepResearchArtifact>( url, - 4_000, + getArtifactRefreshInterval(), ); return { @@ -151,7 +150,7 @@ export function useDeepResearchEvents(sessionId: string | undefined, since?: str : null; const { data, error, isLoading, mutate } = useDeepResearchListResource<DeepResearchEvent>( url, - 2_000, + ACTIVE_DEEP_RESEARCH_REFRESH_MS, ); return { @@ -168,13 +167,7 @@ export function useDeepResearchExecutions(sessionId: string | undefined) { const { data, error, isLoading, mutate } = useDeepResearchResource<PersistedExecutionRecord[]>( url, { - refreshInterval: (latestData) => { - if (!latestData) return 5000; - const hasActive = latestData.some((r) => - ["pending", "submitted", "running"].includes(r.status) - ); - return hasActive ? 5000 : 30000; - }, + refreshInterval: (latestData) => getExecutionRefreshInterval(latestData ?? null), }, ); @@ -197,20 +190,11 @@ interface FullSessionData { executions: PersistedExecutionRecord[]; } -const TERMINAL_STATUSES = new Set(["completed", "stopped_by_user", "failed", "cancelled"]); -const AWAITING_STATUSES = new Set(["awaiting_user_confirmation", "execution_prepared", "awaiting_additional_literature"]); - export function useDeepResearchSessionFull(sessionId: string | undefined) { const url = sessionId ? `/api/deep-research/sessions/${sessionId}/full` : null; const { data, error, isLoading, mutate } = useSWR<FullSessionData>(url, fetcher, { - refreshInterval: (latestData) => { - if (!latestData?.session) return 5000; - const status = latestData.session.status; - if (TERMINAL_STATUSES.has(status)) return 60000; - if (AWAITING_STATUSES.has(status)) return 15000; - return 5000; - }, + refreshInterval: (latestData) => getFullSessionRefreshInterval(latestData?.session ?? null), }); return { diff --git a/src/lib/hooks/use-model-selection.ts b/src/lib/hooks/use-model-selection.ts new file mode 100644 index 00000000..d3d44308 --- /dev/null +++ b/src/lib/hooks/use-model-selection.ts @@ -0,0 +1,191 @@ +"use client"; + +import { useState, useCallback, useMemo } from "react"; +import useSWR from "swr"; +import { + PROVIDERS, + modelSupportsVision, +} from "@/lib/ai/models"; +import type { ProviderId } from "@/lib/ai/models"; +import { + resolveModelSelection, + type ModelCatalogEntry, + type ProviderModelCatalog, +} from "@/lib/ai/model-selection"; + +type ModelSelection = { provider: string; model: string }; +type AgentModelOptionsKey = readonly ["agent-model-options", ...ProviderId[]]; + +function readStoredModelSelection(storageKey: string): ModelSelection | null { + try { + if (typeof window === "undefined" || !window.localStorage) return null; + const stored = window.localStorage.getItem(storageKey); + if (!stored) return null; + const parsed = JSON.parse(stored); + if ( + typeof parsed?.provider === "string" && + parsed.provider && + typeof parsed?.model === "string" && + parsed.model + ) { + return { provider: parsed.provider, model: parsed.model }; + } + } catch { + // Ignore storage access and parse errors. + } + return null; +} + +interface UseModelSelectionOptions { + storageKey: string; + configuredProviderIds: ProviderId[]; + settingsFallback: ModelSelection | null; + /** Fallback display name when no model is resolved */ + fallbackDisplayName?: string; +} + +export function useModelSelection({ + storageKey, + configuredProviderIds, + settingsFallback, + fallbackDisplayName = "Model", +}: UseModelSelectionOptions) { + const [userSelection, setUserSelection] = useState<ModelSelection | null>( + () => readStoredModelSelection(storageKey), + ); + + // --- Discover models from configured providers --- + const modelOptionsKey = useMemo<AgentModelOptionsKey | null>(() => { + if (configuredProviderIds.length === 0) return null; + return ["agent-model-options", ...configuredProviderIds]; + }, [configuredProviderIds]); + + const { data: discoveredModelsByProvider, mutate: refreshDiscoveredModels } = useSWR< + Record<string, ModelCatalogEntry[]> + >( + modelOptionsKey, + async (key: AgentModelOptionsKey) => { + const [, ...providerIds] = key; + const entries = await Promise.all( + providerIds.map(async (providerId) => { + try { + const response = await fetch(`/api/models?provider=${encodeURIComponent(providerId)}`); + const data = await response.json().catch(() => ({})); + return [providerId, Array.isArray(data.models) ? data.models : []] as const; + } catch { + return [providerId, []] as const; + } + }), + ); + return Object.fromEntries(entries); + }, + ); + + // --- Build available providers list --- + const availableProviders = useMemo<ProviderModelCatalog[]>(() => { + const providers: ProviderModelCatalog[] = []; + for (const id of configuredProviderIds) { + const provider = PROVIDERS[id]; + if (!provider) continue; + const knownIds = new Set(provider.models.map((m) => m.id)); + const extraModels = (discoveredModelsByProvider?.[id] ?? []).filter( + (m) => !knownIds.has(m.id), + ); + providers.push({ + id: provider.id, + name: provider.name, + models: [...provider.models, ...extraModels], + }); + } + return providers; + }, [configuredProviderIds, discoveredModelsByProvider]); + + // --- Resolve selection --- + const resolvedSelection = useMemo(() => { + const selection = userSelection ?? settingsFallback; + const unmatchedKind = + selection && (discoveredModelsByProvider?.[selection.provider]?.length ?? 0) > 0 + ? "not-found" + : "custom"; + return resolveModelSelection(selection, availableProviders, { unmatchedKind }); + }, [availableProviders, discoveredModelsByProvider, settingsFallback, userSelection]); + + const canonicalSelection = useMemo<ModelSelection | null>( + () => + resolvedSelection + ? { provider: resolvedSelection.provider, model: resolvedSelection.resolvedModel } + : null, + [resolvedSelection], + ); + + // --- Sync selection during render when provider is removed or canonical drifts --- + const isProviderValid = + userSelection && + Boolean(PROVIDERS[userSelection.provider as ProviderId]) && + (configuredProviderIds.length === 0 || + configuredProviderIds.includes(userSelection.provider as ProviderId)); + + if (userSelection && !isProviderValid) { + setUserSelection(null); + try { + if (typeof window !== "undefined" && window.localStorage) { + window.localStorage.removeItem(storageKey); + } + } catch { /* ignore */ } + } + + if ( + userSelection && + isProviderValid && + canonicalSelection && + (canonicalSelection.provider !== userSelection.provider || + canonicalSelection.model !== userSelection.model) + ) { + setUserSelection(canonicalSelection); + try { + if (typeof window !== "undefined" && window.localStorage) { + window.localStorage.setItem(storageKey, JSON.stringify(canonicalSelection)); + } + } catch { /* ignore */ } + } + + const selectedProvider = canonicalSelection?.provider ?? null; + const selectedModel = canonicalSelection?.model ?? null; + + const handleModelChange = useCallback( + (providerId: string, modelId: string) => { + setUserSelection({ provider: providerId, model: modelId }); + try { + if (typeof window !== "undefined" && window.localStorage) { + window.localStorage.setItem( + storageKey, + JSON.stringify({ provider: providerId, model: modelId }), + ); + } + } catch { + // Ignore storage access errors. + } + }, + [storageKey], + ); + + const modelDisplayName = resolvedSelection?.displayName ?? fallbackDisplayName; + + const selectedSupportsVision = useMemo(() => { + if (!selectedProvider || !selectedModel || resolvedSelection?.matchKind === "unmatched") { + return null; + } + return modelSupportsVision(selectedProvider, selectedModel); + }, [resolvedSelection?.matchKind, selectedProvider, selectedModel]); + + return { + selectedProvider, + selectedModel, + modelDisplayName, + resolvedSelection, + availableProviders, + selectedSupportsVision, + handleModelChange, + refreshDiscoveredModels, + }; +} diff --git a/src/lib/paper-discussion/prompts.ts b/src/lib/paper-discussion/prompts.ts index 82de60d8..12aa8b2a 100644 --- a/src/lib/paper-discussion/prompts.ts +++ b/src/lib/paper-discussion/prompts.ts @@ -22,12 +22,14 @@ Your task: Frame the discussion. Define the agenda. Identify key technical quest evidence_summary: `CURRENT STAGE: Evidence Summary Your task: Summarize the paper's claims, method, setup, and results. Ground everything with evidence from the paper. - Present what the paper explicitly says vs. what is inferred vs. what is missing. -- Attach evidence references whenever available.`, +- Attach evidence references whenever available. +- When referencing external work for comparison, use numbered inline citations **[N]** and include a References section at the end with full markdown-formatted entries.`, critique: `CURRENT STAGE: Critical Analysis Your task: Challenge the evidence and claims. Identify weaknesses, missing baselines, threats to validity, and overclaims. - Mark each issue with severity: Critical / Moderate / Minor. -- Separate confirmed weaknesses from potential concerns.`, +- Separate confirmed weaknesses from potential concerns. +- When identifying missing baselines or comparing to external methods, cite specific papers using numbered inline citations **[N]** and include a References section at the end.`, reproducibility_check: `CURRENT STAGE: Reproducibility Check Your task: Assess reproducibility. Extract implementation-critical details. Identify gaps in what's needed to reproduce the results. @@ -41,8 +43,9 @@ Your task: Synthesize the discussion. Summarize agreement, disagreement, and ope final_report: `CURRENT STAGE: Final Report Your task: Write the final structured report synthesizing the entire discussion. -- Use EXACTLY the required output format with all 7 sections. +- Use EXACTLY the required output format with all 8 sections (including ## 8. References). - Do not introduce new claims that were not discussed. +- Consolidate all numbered inline citations from the discussion into the References section with full markdown formatting: N. **Author(s)** (Year). *Title.* Venue. [DOI/URL](link) - End with "Overall take: ..."`, }; diff --git a/src/lib/paper-discussion/roles.ts b/src/lib/paper-discussion/roles.ts index 0e988714..6e4fb2e9 100644 --- a/src/lib/paper-discussion/roles.ts +++ b/src/lib/paper-discussion/roles.ts @@ -17,7 +17,16 @@ Global rules: - Do not fabricate experimental details, citations, or code availability. - Keep outputs compact, technical, and useful. - Respect your role boundary. -- Avoid repeating points already made unless you are refining or challenging them.`; +- Avoid repeating points already made unless you are refining or challenging them. + +Citation rules (MANDATORY): +- When referencing knowledge, methods, findings, or claims from outside the paper under discussion, you MUST provide numbered inline citations (e.g. **[1]**, **[2]**) in the text body. +- All cited references MUST be collected in a **References** section at the end of your output. +- Each reference MUST use this markdown format: + N. **AuthorLastName, A. et al.** (Year). *Paper Title.* Journal/Venue. [DOI:xxx](https://doi.org/xxx) or [URL](url) +- Only cite references you are confident are real published works. If you are uncertain about a reference's accuracy, do NOT cite it — instead mark the claim as **[needs verification]**. +- Do NOT fabricate or hallucinate references. It is better to have fewer citations than fake ones. +- The paper under discussion itself should be cited as **[0]** at the top of the References section.`; // ============================================================= // ROLE SYSTEM PROMPTS — verbatim from requirements @@ -97,9 +106,12 @@ Output structure: 7. Citations / evidence anchors Citation behavior: -- Attach evidence references whenever available. +- Attach numbered inline citations (e.g. **[1]**, **[2]**) for every external claim, method, or finding you reference. +- Collect all references in a **References** section at the end using markdown format: + N. **Author(s)** (Year). *Title.* Venue. [DOI/URL](link) +- Only cite references you are confident are real. Do NOT fabricate references. - If exact citations are unavailable in the current context, say: - "Evidence not directly available in current retrieved context." + "Evidence not directly available in current retrieved context — **[needs verification]**." Tone: - Precise, neutral, scholarly, compact.`; @@ -121,6 +133,7 @@ Rules: - Do not invent flaws unsupported by the available context. - Separate "confirmed weakness" from "potential concern". - Prefer technical critique over generic reviewer language. +- When identifying missing baselines or comparing to external methods, cite specific works using numbered inline citations **[N]** and list them in a References section. Output structure: 1. Top claim under scrutiny @@ -245,6 +258,10 @@ Required output format: - Ask authors / inspect code for: - Whether this paper is worth deeper follow-up: +## 8. References +[0]. **[Paper authors]** (Year). *[Paper title].* Venue. [DOI/URL](link) +[Collect all numbered inline citations from the discussion into this section. Each entry must use markdown: **Author(s)** (Year). *Title.* Venue. [DOI](link)] + Writing guidance: - Be crisp and hierarchical. - Prefer bullets over paragraphs. diff --git a/src/lib/rag/vector-store.ts b/src/lib/rag/vector-store.ts index 0e709698..368e6045 100644 --- a/src/lib/rag/vector-store.ts +++ b/src/lib/rag/vector-store.ts @@ -10,24 +10,28 @@ import { eq } from "drizzle-orm"; // In-memory cache of embeddings: chunkId -> number[] const embeddingCache = new Map<string, number[]>(); +let vectorTableInitialized = false; // Initialize the embeddings table -function initializeVectorTable() { +function ensureVectorTable() { + if (vectorTableInitialized) { + return; + } + sqlite.exec(` CREATE TABLE IF NOT EXISTS chunk_embeddings ( chunk_id TEXT PRIMARY KEY, embedding BLOB NOT NULL ) `); + vectorTableInitialized = true; } -// Ensure table exists -initializeVectorTable(); - /** * Store an embedding for a chunk */ export function insertEmbedding(chunkId: string, embedding: number[]): void { + ensureVectorTable(); const buffer = Buffer.from(new Float32Array(embedding).buffer); sqlite @@ -45,6 +49,7 @@ export function insertEmbedding(chunkId: string, embedding: number[]): void { export function insertEmbeddings( items: { chunkId: string; embedding: number[] }[] ): void { + ensureVectorTable(); const stmt = sqlite.prepare( "INSERT OR REPLACE INTO chunk_embeddings (chunk_id, embedding) VALUES (?, ?)" ); @@ -67,6 +72,7 @@ export function insertEmbeddings( */ export function deleteEmbeddings(chunkIds: string[]): void { if (chunkIds.length === 0) return; + ensureVectorTable(); const placeholders = chunkIds.map(() => "?").join(","); sqlite @@ -115,6 +121,7 @@ function cosineSimilarity(a: number[], b: number[]): number { * Load an embedding from DB or cache */ function loadEmbedding(chunkId: string): number[] | null { + ensureVectorTable(); if (embeddingCache.has(chunkId)) { return embeddingCache.get(chunkId)!; } diff --git a/src/lib/research-ideation/prompts.ts b/src/lib/research-ideation/prompts.ts index 71f93cf3..f2fecea5 100644 --- a/src/lib/research-ideation/prompts.ts +++ b/src/lib/research-ideation/prompts.ts @@ -17,8 +17,10 @@ const STAGE_GUIDANCE: Record<IdeationStageId, string> = { hypothesis_generation: `CURRENT STAGE: Hypothesis Generation Your task: Read the seed paper. Produce 3-5 novel, testable research hypotheses that extend, challenge, or build upon the paper's findings. - Each hypothesis must have a clear statement, rationale, novelty assessment, and connection to the seed paper. +- Each hypothesis MUST include at least 1 supporting reference as a numbered inline citation **[N]**. - If the user provided a seed idea, incorporate it as a starting point. -- Prioritize hypotheses that balance novelty with feasibility.`, +- Prioritize hypotheses that balance novelty with feasibility. +- End with a **References** section listing all cited works in markdown format.`, feasibility_review: `CURRENT STAGE: Feasibility Review Your task: For each hypothesis from Stage 1, evaluate practical feasibility across five dimensions: data availability, compute requirements, methodological readiness, timeline, and risk. @@ -29,8 +31,10 @@ Your task: For each hypothesis from Stage 1, evaluate practical feasibility acro experiment_design: `CURRENT STAGE: Experiment Design Your task: Select the top 2 most feasible hypotheses based on the Feasibility Checker's assessment. Design concrete, executable experiments for each. - Include protocol, baselines, controls, metrics, and expected outcomes. +- For each baseline method and adopted protocol, provide numbered inline citations **[N]** to the original papers. - Define a Minimum Viable Experiment (MVE) achievable in 1-2 weeks. -- Be specific enough that a graduate student could begin implementation.`, +- Be specific enough that a graduate student could begin implementation. +- End with a **References** section listing all cited works in markdown format.`, review: `CURRENT STAGE: Review & Critique Your task: Review the full transcript from all prior stages. Identify logical gaps, ethical concerns, statistical issues, missing baselines, and scope problems. @@ -40,8 +44,9 @@ Your task: Review the full transcript from all prior stages. Identify logical ga final_report: `CURRENT STAGE: Final Report Your task: Synthesize the entire transcript into a structured Research Ideation Report. -- Use the required output format with all 6 sections. +- Use the required output format with all 7 sections (including ## 7. References). - Do not introduce new ideas not discussed in the transcript. +- Consolidate all numbered inline citations from the discussion into the References section with full markdown formatting: N. **Author(s)** (Year). *Title.* Venue. [DOI/URL](link) - Preserve nuance — include both promise and risk for each direction. - End with an overall assessment of the most promising research direction.`, }; diff --git a/src/lib/research-ideation/roles.ts b/src/lib/research-ideation/roles.ts index 8ac2b4fd..bdda4377 100644 --- a/src/lib/research-ideation/roles.ts +++ b/src/lib/research-ideation/roles.ts @@ -18,7 +18,16 @@ Rules every panelist must follow: 4. Be constructive: point out limitations, then suggest fixes. 5. Respect the stage you are in — do not anticipate later stages. 6. Write in the locale requested by the user (en / zh). -7. In quick mode be concise and focus on the most critical points; in full mode be comprehensive.`; +7. In quick mode be concise and focus on the most critical points; in full mode be comprehensive. + +Citation rules (MANDATORY): +8. When referencing external knowledge, methods, findings, datasets, or claims, you MUST provide numbered inline citations (e.g. **[1]**, **[2]**) in the text body. +9. All cited references MUST be collected in a **References** section at the end of your output. +10. Each reference MUST use this markdown format: + N. **AuthorLastName, A. et al.** (Year). *Paper Title.* Journal/Venue. [DOI:xxx](https://doi.org/xxx) or [URL](url) +11. Only cite references you are confident are real published works. If uncertain, mark the claim as **[needs verification]** instead. +12. Do NOT fabricate or hallucinate references. Fewer real citations are always better than fake ones. +13. The seed paper itself should be cited as **[0]** at the top of the References section.`; // ============================================================= // ROLE SYSTEM PROMPTS @@ -54,6 +63,10 @@ For each hypothesis: - **Novelty**: [What makes this different from existing work] - **Connection to seed paper**: [How this extends or challenges the paper] - **Estimated impact**: High / Medium / Low +- **Supporting references**: [At least 1 numbered inline citation **[N]** to relevant prior work] + +End with a **References** section listing all cited works in markdown format: +N. **Author(s)** (Year). *Title.* Venue. [DOI/URL](link) Tone: - Creative but rigorous, specific, forward-looking.`; @@ -116,13 +129,17 @@ Output structure: For each selected hypothesis: ### Experiment for Hypothesis [N]: [Title] - **Protocol**: [Step-by-step] -- **Baselines**: [List with justification] +- **Baselines**: [List with justification and inline citations **[N]** for each baseline method] - **Controls & Ablations**: [What to vary] - **Metrics**: [Primary and secondary] - **Expected outcome if hypothesis holds**: [Description] - **Expected outcome if hypothesis fails**: [Description] - **Minimum Viable Experiment**: [Simplified version] - **Timeline**: [Estimate for MVE and full experiment] +- **Key references**: [Cite sources for baseline methods and adopted protocols] + +End with a **References** section listing all cited works in markdown format: +N. **Author(s)** (Year). *Title.* Venue. [DOI/URL](link) Tone: - Precise, operational, engineering-minded.`; @@ -223,6 +240,10 @@ For each selected hypothesis: ## 6. Overall Assessment [2-3 sentence verdict on the most promising research direction and why] +## 7. References +[0]. **[Seed paper authors]** (Year). *[Seed paper title].* Venue. [DOI/URL](link) +[Consolidate all numbered inline citations from the entire discussion. Each entry uses markdown: **Author(s)** (Year). *Title.* Venue. [DOI](link)] + Writing guidance: - Be crisp and hierarchical. - Prefer bullets over paragraphs. diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 00000000..09e46c62 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,10 @@ +export interface PublicUser { + id: string; + email: string; + name: string; + role: "admin" | "user"; + isActive: boolean; + lastLoginAt: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/tsconfig.json b/tsconfig.json index cf9c65d3..759e6d7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -19,7 +23,9 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, "include": [ @@ -30,5 +36,7 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }